Warm-up · Activity 1 of 7
Warm-up from JavaScript: two objects with the same key are spread into one. What does this print?
const base = { id: 1, status: "open" };
const extra = { status: 404, owner: "Ada" };
console.log({ ...base, ...extra });// B5.3 · ~30 min · Beginner
After this lesson you can build types from other types with extends and &, predict what happens when they clash, and pick the right one.
You will be able to
Warm-up · Activity 1 of 7
const base = { id: 1, status: "open" };
const extra = { status: 404, owner: "Ada" };
console.log({ ...base, ...extra });Predict · Activity 2 of 7
interface Person1 {
name: string;
}
interface Person2 {
name: number;
}
type Staff = Person1 & Person2;
const ada: Staff = { name: "Ada" };Practice · Activity 3 of 7
interface Draft {
title?: string;
body: string;
}
interface Published extends Draft {
____;
}
function headline(post: Published): string {
return post.title.toUpperCase();
}
console.log(headline({ title: "Hello", body: "First post" }));Practice · Activity 4 of 7
interface Product {
price: number;
}
interface Listing {
price: string;
}
interface Offer extends Product, Listing {}Practice · Activity 5 of 7
type Defaults = { port: number; host: string };
type Secure = { port: 443 | 8443 };
function connect(config: Defaults & Secure) {
const port = config.port;
// here
return config.host + ":" + port;
}Brain teaser · Activity 6 of 7
type Cat = { kind: "cat"; lives: number };
type Dog = { kind: "dog"; bark: string };
type CatDog = Cat & Dog;
function describe(pet: CatDog) {
return pet.lives;
}Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Bug extends Ticket, adds severity and narrows status to two values. AssignedBug extends two interfaces at once. line combines AssignedBug with Timestamped by & right in its parameter. shortLine takes any Ticket, so a Bug fits too. Closed intersects Ticket with a narrower status. Run npx tsc, then node main.ts. Then give Bug the member status: number, or change Timestamped to { createdAt: number }, and compare where tsc reports each.
main.ts
// The fields every ticket has.
interface Ticket {
readonly id: number;
title: string;
status: string;
}
// extends adds severity, and makes status more specific: still a string.
interface Bug extends Ticket {
status: "open" | "fixed";
severity: 1 | 2 | 3;
}
// Several bases at once.
interface Owned {
owner: string;
}
interface AssignedBug extends Bug, Owned {}
// & combines types where you need them, without a new interface.
type Timestamped = { createdAt: string };
function line(bug: AssignedBug & Timestamped): string {
return "#" + bug.id + " " + bug.title + " [" + bug.status + ", sev " + bug.severity + "] " + bug.owner + ", " + bug.createdAt;
}
// Any Bug is also a Ticket, so it fits where a Ticket is expected.
function shortLine(ticket: Ticket): string {
return "#" + ticket.id + " " + ticket.status;
}
const crash: AssignedBug & Timestamped = {
id: 7,
title: "Crash on save",
status: "open",
severity: 1,
owner: "Ada",
createdAt: "2026-10-04"
};
console.log(line(crash));
console.log(shortLine(crash));
// Both sides declare status: the intersection keeps what fits both.
type Closed = Ticket & { status: "fixed" | "wontfix" };
const old: Closed = { id: 3, title: "Typo in footer", status: "wontfix" };
console.log(shortLine(old));
Run it with
npx tsc
node main.tsOutput
#7 Crash on save [open, sev 1] Ada, 2026-10-04
#7 open
#3 wontfixTab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.
Exercise 1 of 2
Admin extends Account but declares plan: number, so tsc reports error TS2430: Interface 'Admin' incorrectly extends interface 'Account'. An admin's plan is always "team" or "enterprise": declare plan again with that literal union, which fits Account's string. The // @ts-expect-error line then still finds its error, because "free" is not allowed. Then make canDelete return whether the admin's permissions include "delete". Run the tests: the first one is the type-check.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.
An interface that extends another may declare an inherited property again, if the new type fits the old one.
"team" | "enterprise" is a union of string literals, so it fits string: plan: "team" | "enterprise";
Arrays have includes: return admin.permissions.includes("delete");
One way to solve it. Yours can look different and still pass the checks.
export interface Account {
readonly id: number;
email: string;
plan: string;
}
export interface Admin extends Account {
plan: "team" | "enterprise";
permissions: string[];
}
export function summary(admin: Admin): string {
return admin.email + " (" + admin.plan + ", " + admin.permissions.length + " permissions)";
}
export function canDelete(admin: Admin): boolean {
return admin.permissions.includes("delete");
}
// @ts-expect-error: an admin's plan is "team" or "enterprise"
const wrong: Admin = { id: 9, email: "eve@example.com", plan: "free", permissions: [] };
Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.ts
export interface Account {
readonly id: number;
email: string;
plan: string;
}
export interface Admin extends Account {
plan: number;
permissions: string[];
}
export function summary(admin: Admin): string {
return admin.email + " (" + admin.plan + ", " + admin.permissions.length + " permissions)";
}
export function canDelete(admin: Admin): boolean {
return false;
}
// @ts-expect-error: an admin's plan is "team" or "enterprise"
const wrong: Admin = { id: 9, email: "eve@example.com", plan: "free", permissions: [] };
main.test.ts
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {summary, canDelete} from './main.ts';
const ada = {id: 1, email: 'ada@example.com', plan: 'team' as const, permissions: ['read', 'delete']};
const bob = {id: 2, email: 'bob@example.com', plan: 'enterprise' as const, permissions: ['read']};
test('summary shows the email, the plan and the number of permissions', () => {
const got = summary(ada);
assert.equal(got, 'ada@example.com (team, 2 permissions)', `summary(ada) gave ${JSON.stringify(got)}`);
});
test('an admin with the delete permission can delete', () => {
assert.equal(canDelete(ada), true, 'canDelete(ada) should be true: ada has "delete"');
});
test('an admin without it cannot', () => {
assert.equal(canDelete(bob), false, 'canDelete(bob) should be false: bob has only "read"');
});
package.json
{
"type": "module"
}
tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"lib": [
"esnext",
"dom"
],
"types": [],
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
},
"include": [
"**/*.ts"
],
"exclude": [
"**/*.test.ts"
]
}
npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.
Run the program:
npx tsc
node main.tsRun the checks (needs learnrun.js in the same folder):
npx tsc
node --testDownload learnrun.jsExercise 2 of 2
Doc combines three types with &. Timestamped says createdAt is a string, Versioned says it is a number, so createdAt is never, and notes is error TS2322: Type 'string' is not assignable to type 'never'. In Versioned, replace createdAt with savedAt, a string: when the current version was saved. Give notes the savedAt 2026-10-03. label must return Notes v2 (created 2026-10-01, saved 2026-10-03), and bump(doc, savedAt) a new Doc with the version raised by one and the given savedAt.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.
The error is at notes, but the cause is in the types: which property do Timestamped and Versioned both declare?
type Versioned = { version: number; savedAt: string }; then add savedAt: "2026-10-03" to notes.
In bump, put savedAt into the spread after version: { ...doc, version: doc.version + 1, savedAt }
One way to solve it. Yours can look different and still pass the checks.
export type Titled = { title: string };
export type Timestamped = { createdAt: string };
export type Versioned = { version: number; savedAt: string };
export type Doc = Titled & Timestamped & Versioned;
export function label(doc: Doc): string {
return doc.title + " v" + doc.version + " (created " + doc.createdAt + ", saved " + doc.savedAt + ")";
}
export function bump(doc: Doc, savedAt: string): Doc {
return { ...doc, version: doc.version + 1, savedAt };
}
export const notes: Doc = { title: "Notes", version: 2, createdAt: "2026-10-01", savedAt: "2026-10-03" };
Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.ts
export type Titled = { title: string };
export type Timestamped = { createdAt: string };
export type Versioned = { version: number; createdAt: number };
export type Doc = Titled & Timestamped & Versioned;
export function label(doc: Doc): string {
return doc.title + " v" + doc.version + " (created " + doc.createdAt + ")";
}
export function bump(doc: Doc, savedAt: string): Doc {
return { ...doc, version: doc.version + 1 };
}
export const notes: Doc = { title: "Notes", version: 2, createdAt: "2026-10-01" };
main.test.ts
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {label, bump, notes} from './main.ts';
test('label shows the version, when the doc was created and when it was saved', () => {
const got = label(notes);
assert.equal(got, 'Notes v2 (created 2026-10-01, saved 2026-10-03)', `label(notes) gave ${JSON.stringify(got)}`);
});
test('bump raises the version and records when it was saved', () => {
const got = bump(notes, '2026-10-04');
assert.deepEqual(
got,
{title: 'Notes', version: 3, createdAt: '2026-10-01', savedAt: '2026-10-04'},
`bump(notes, '2026-10-04') gave ${JSON.stringify(got)}`
);
});
test('bump leaves the original doc unchanged', () => {
bump(notes, '2026-10-04');
assert.equal(notes.version, 2, `notes.version is ${notes.version} after bump, but bump should return a new doc`);
});
package.json
{
"type": "module"
}
tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"lib": [
"esnext",
"dom"
],
"types": [],
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true
},
"include": [
"**/*.ts"
],
"exclude": [
"**/*.test.ts"
]
}
npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.
Run the program:
npx tsc
node main.tsRun the checks (needs learnrun.js in the same folder):
npx tsc
node --testDownload learnrun.jsinterface Article {
title: string;
body: string;
}
interface Preview extends Article {
body?: string;
}
What tsc or Node.js prints
main.ts(6,11): error TS2430: Interface 'Preview' incorrectly extends interface 'Article'.Why, and the fix
A Preview must still be an Article, and an Article always has a body. body?: string allows undefined, which string does not, so tsc names the property on the next lines: Types of property 'body' are incompatible. extends can only make a property more specific. If a preview really has no body, do not extend Article: declare the shared part as its own interface, such as interface Titled { title: string }, and let both extend that.
type Product = { name: string; price: number };
type Listing = { name: string; price: string };
type Offer = Product & Listing;
const offer: Offer = { name: "Lamp", price: 25 };
What tsc or Node.js prints
main.ts(5,38): error TS2322: Type 'number' is not assignable to type 'never'.Why, and the fix
tsc accepts type Offer, because & never reports a clash. price must be a number and a string at once, so its type is never, and every value fails where it is created. When you see never in a message about a property, look at the types that were combined with &. Decide which type is right and change the other, or write the types as interfaces with extends, so tsc reports the clash at the declaration.
type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
interface Tagged extends Shape {
id: string;
}
What tsc or Node.js prints
main.ts(3,26): error TS2312: An interface can only extend an object type or intersection of object types with statically known members.Why, and the fix
An interface needs one known set of members, and a union is several shapes. Use & instead: type Tagged = Shape & { id: string }. That is a circle with an id or a square with an id, and narrowing on kind still works.
TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.