Extending Interfaces

8 min read

Test data layers naturally. Every entity has an id and timestamps. Every user has a name and email. Some users are admins with permissions. Some test cases are manual, some are automated. Copy-pasting fields across interfaces is fragile — change one shape and the others drift. Extending lets you build bigger interfaces from smaller ones, sharing the common parts and adding only what's new.

extends — inherit every field

Use extends to declare an interface that includes everything from another:

interface BaseUser {
  id: number;
  name: string;
  email: string;
}
 
interface AdminUser extends BaseUser {
  permissions: string[];
  department: string;
}
 
const alice: AdminUser = {
  id: 1,
  name: "Alice",
  email: "alice@test.com",
  permissions: ["users:read", "users:write"],
  department: "Platform",
};

AdminUser has five fields total — three inherited, two new. Anywhere a BaseUser is accepted, an AdminUser will fit (it has all the required fields). The reverse isn't true: a BaseUser is missing permissions and department, so it can't stand in for an AdminUser.

Extending multiple interfaces

extends accepts a comma-separated list. The resulting interface inherits every field from every parent.

interface Timestamps {
  createdAt: string;
  updatedAt: string;
}
 
interface SoftDelete {
  deletedAt: string | null;
  isDeleted: boolean;
}
 
interface FullUser extends BaseUser, Timestamps, SoftDelete {
  lastLogin: string;
}
 
const carol: FullUser = {
  id: 3,
  name: "Carol",
  email: "carol@test.com",
  createdAt: "2026-01-12T10:00:00Z",
  updatedAt: "2026-05-04T08:14:00Z",
  deletedAt: null,
  isDeleted: false,
  lastLogin: "2026-05-04T07:55:30Z",
};

FullUser has eight fields, every one inherited or newly declared. If a parent later gains a field, every descendant picks it up automatically — that's the power of composing types this way.

extends vs intersection (&)

You met intersections in chapter 2type FullUser = User & Timestamps & Permissions. Both extends (on interfaces) and & (on type aliases) compose smaller types into bigger ones, and for most QA-shaped data the result is identical. The differences:

  • extends is an error if the parent and child declare the same property with incompatible types. & silently produces a property of type never.
  • extends only works on interface. & works on any type, including unions and primitives.
  • interface extends shows up more cleanly in IDE tooltips — they show the inheritance chain rather than a flattened union of fields.

For object hierarchies you control, prefer interface extends. For composing across the boundary between types and interfaces, & is the only tool that crosses both.

Specialising fields when extending

A child interface can narrow an inherited field to a more specific type, as long as the new type is assignable to the parent's:

interface BaseTestCase {
  id: string;
  title: string;
  status: string;
}
 
interface ManualTestCase extends BaseTestCase {
  status: "not-run" | "passed" | "failed";   // narrower than parent's string
  expectedResult: string;
  actualResult?: string;
}

status on ManualTestCase is "not-run" | "passed" | "failed", a closed set. The parent's string allowed any text; the child says "but for this kind of test case, only these three values." This is a common pattern when a base type is loose for compatibility and specific descendants tighten the contract.

(Note: widening a type when extending — making the child accept more values than the parent — is a compile error. The override has to be assignable to the parent's type, not the other way around.)

Two specialisations of one base

Test cases come in flavours. Manual ones live in a doc and run by hand; automated ones live in a script and run in CI. Both share an id, a title, and a list of steps; from there they diverge.

interface BaseTestCase {
  id: string;
  title: string;
  steps: string[];
}
 
interface ManualTestCase extends BaseTestCase {
  expectedResult: string;
  actualResult?: string;
  status: "not-run" | "passed" | "failed";
}
 
interface AutomatedTestCase extends BaseTestCase {
  scriptPath: string;
  framework: "cypress" | "playwright";
  lastRunDuration?: number;
}

A function that operates on the shared base — like printSteps(tc: BaseTestCase) — can accept either subtype. A function specific to one flavour — runScript(tc: AutomatedTestCase) — only accepts the right shape, and the compiler enforces it. You're getting the benefits of inheritance without writing a single class.

A test data hierarchy at a glance

The tree mirrors how real domain models grow: a thin shared base, branches per entity, deeper branches for specialisations. Test fixtures, factories, and assertion helpers can be written against whichever level of the tree they actually need.

When extending earns its keep

Three signals that an extends chain is justified:

  • Two or more interfaces share three or more fields — pull the shared fields into a base.
  • You'd otherwise paste the same five lines into every interface — and have to update them all when one changes.
  • A function naturally operates on "anything that has these fields" — that's the parent interface in disguise.

If two interfaces share only one field, or the fields are coincidentally similar but conceptually unrelated, don't extract a base. Forcing inheritance on accidentally-shared fields creates surprise coupling. The rule of thumb: extract bases that have a real meaning, not bases that are statistical correlations.

⚠️ Common mistakes

  • Inheriting from a type with conflicting fields. interface Child extends ParentA, ParentB where both parents declare status: string (one as string, one as boolean) — TypeScript complains because the merged property has no compatible type. Either align the parents or rethink the inheritance; a child can't satisfy two contradictory contracts.
  • Building deep hierarchies because they "feel object-oriented." BaseEntity ← User ← AdminUser ← SuperAdminUser ← PlatformAdminUser collapses under its own weight. Two levels is usually plenty; three is the maximum I'd reach for. If you find yourself going deeper, consider composition (intersect smaller pieces) rather than another extends.
  • Forgetting that a child can stand in for a parent, but not the reverse. A function that takes BaseUser will accept any AdminUser. A function that takes AdminUser will not accept a plain BaseUser — it's missing the admin-only fields. Type your function parameters to the minimum shape they actually need so callers have flexibility.

🎯 Practice task

Build a layered test data model. 20-30 minutes.

  1. In your ts-for-qa/src folder, create model-layers.ts.
  2. Define interface BaseEntity { id: string; createdAt: string; updatedAt: string }.
  3. Define three children:
    • interface User extends BaseEntity { name: string; email: string; role: "admin" | "tester" | "viewer" }
    • interface Product extends BaseEntity { sku: string; price: number; inStock: boolean }
    • interface Order extends BaseEntity { userId: string; productIds: string[]; total: number }
  4. Define interface AdminUser extends User { permissions: string[]; department: string } and override role to be exactly "admin".
  5. Create one value for each interface. Run with npx ts-node src/model-layers.ts.
  6. Write function printIdentity(e: BaseEntity): void that logs e.id and the timestamps. Pass each of your four values to it — confirm they all type-check.
  7. Try the inheritance edge cases. After each, read the error then revert:
    • Pass a plain BaseEntity (no email/name) into a function that takes User.
    • Override role on AdminUser to "viewer" and try assigning an admin value.
  8. Stretch: add interface Timestamps { startedAt?: string; finishedAt?: string } and create interface OrderRun extends Order, Timestamps. Confirm the merged type has every field from both parents.

The next lesson is the chapter's wrap-up — when to use interface vs type for your object shapes, and what each does that the other can't.

// tip to track lessons you complete and pick up where you left off across devices.