Skip to content
aviral gupta

// B5.3 · ~30 min · Beginner

Extending types and intersections

After this lesson you can build types from other types with extends and &, predict what happens when they clash, and pick the right one.

Lesson 3 of 5 in B5 Object types

You will be able to

  • Build an interface on one or several others with extends, and make an inherited property more specific
  • Combine types with &, and predict the type of a property that both sides declare
  • Explain where extends and & report a conflict, and choose between them
  1. 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 });
  2. Predict · Activity 2 of 7

    Predict before you read on: Staff combines two types that disagree about name. What does tsc report?

    interface Person1 {
      name: string;
    }
    
    interface Person2 {
      name: number;
    }
    
    type Staff = Person1 & Person2;
    
    const ada: Staff = { name: "Ada" };
  3. Practice · Activity 3 of 7

    A draft may have no title yet; a published post must have one. Fill in the member that Published declares again, so that headline compiles and the program prints HELLO.

    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" }));
    ;
  4. Practice · Activity 4 of 7

    Offer extends two interfaces that disagree about price. What does tsc report?

    interface Product {
      price: number;
    }
    
    interface Listing {
      price: string;
    }
    
    interface Offer extends Product, Listing {}
  5. Practice · Activity 5 of 7

    Both types declare port. Which type does port have at the comment?

    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;
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. A pet that is a Cat and a Dog at once: what does tsc report?

    type Cat = { kind: "cat"; lives: number };
    type Dog = { kind: "dog"; bark: string };
    type CatDog = Cat & Dog;
    
    function describe(pet: CatDog) {
      return pet.lives;
    }
  7. Apply · Activity 7 of 7

    Mini-task. Write an interface Contact with a name and an optional email, and an interface Customer that extends it, makes email required and adds a customerNo (a number). Write card(c), whose parameter is a Customer combined with { note: string } by &, and which returns text such as Ada <ada@example.com> #17: prefers email. Call it once. Then make Customer declare email: number and read the error, and try { note: number } & { note: string } as well. Check it in the editor, or with npx tsc and node main.ts.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Tickets that build on each other

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.ts

Output

#7 Crash on save [open, sev 1] Ada, 2026-10-04
#7 open
#3 wontfix
  • status: "open" | "fixed" fits status: string, so Bug may declare it again.
  • crash is an AssignedBug & Timestamped and still fits shortLine, which wants only a Ticket.
  • In Closed, status is "fixed" | "wontfix": the part of string that both sides allow.
  • status: number in Bug would be error TS2430 at Bug; createdAt: number in Timestamped would only show up at crash, as never.
Change it and run it

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.

Exercises

Exercise 1 of 2

Admins are accounts with more rules

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.

Hints
  1. Hint 1

    An interface that extends another may declare an inherited property again, if the new type fits the old one.

  2. Hint 2

    "team" | "enterprise" is a union of string literals, so it fits string: plan: "team" | "enterprise";

  3. Hint 3

    Arrays have includes: return admin.permissions.includes("delete");

Show a solution

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: [] };
Run it on your computer

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.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 2 of 2

A document type with a hidden clash

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.

Hints
  1. Hint 1

    The error is at notes, but the cause is in the types: which property do Timestamped and Versioned both declare?

  2. Hint 2

    type Versioned = { version: number; savedAt: string }; then add savedAt: "2026-10-03" to notes.

  3. Hint 3

    In bump, put savedAt into the spread after version: { ...doc, version: doc.version + 1, savedAt }

Show a solution

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" };
Run it on your computer

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.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Common mistakes

Making an inherited property optional

interface 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.

A clash in an intersection, found far from its cause

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.

Extending a union

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

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

extends may narrow a property, never contradict it

B2.4 showed that interface Bug extends Ticket copies Ticket's members into Bug. Bug may also declare one of them again, as long as the new type fits the old one: status: string can become status: "open" | "fixed", and an optional title?: string can become a required title: string. Every Bug is then still a Ticket and fits where a Ticket is expected. The other direction is error TS2430: Interface 'Bug' incorrectly extends interface 'Ticket'., followed by lines naming the property. With several bases, extends A, B, the bases must agree on shared properties, or tsc reports TS2320: Interface 'C' cannot simultaneously extend types 'A' and 'B'. An interface can extend an object type alias, but not a union: TS2312.

& intersects the types of shared properties

A & B has every property of A and of B, and excess property checks still apply. When both sides declare the same property, & does not pick one, as a spread would: the property gets both types at once. { port: number } & { port: 443 | 8443 } gives port the type 443 | 8443. When the types cannot overlap, the result is never, a type with no values: in { name: string } & { name: number }, name is never, and { name: "Ada" } is error TS2322: Type 'string' is not assignable to type 'never'. When literal types clash, as kind: "cat" and kind: "dog", tsc reduces the whole intersection to never.

Where a conflict shows up, and which to pick

The Handbook: "The principal difference between the two is how conflicts are handled". extends checks the new interface against its bases where you declare it, so a clash is reported on the interface line. & never complains at the type alias: the clash becomes never and surfaces later, wherever a value is created or a property is used, often far away and with a puzzling message such as Property 'lives' does not exist on type 'never'. So use interface … extends for named types that build on each other, where you want that early check. Use & to combine types at the place you use them, or when one side is not an interface, such as a union.

Sources

Last reviewed October 4, 2026