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 2 — type 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:
extendsis an error if the parent and child declare the same property with incompatible types.&silently produces a property of typenever.extendsonly works oninterface.&works on any type, including unions and primitives.interface extendsshows 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, ParentBwhere both parents declarestatus: string(one asstring, one asboolean) — 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 ← PlatformAdminUsercollapses 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 anotherextends. - Forgetting that a child can stand in for a parent, but not the reverse. A function that takes
BaseUserwill accept anyAdminUser. A function that takesAdminUserwill not accept a plainBaseUser— 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.
- In your
ts-for-qa/srcfolder, createmodel-layers.ts. - Define
interface BaseEntity { id: string; createdAt: string; updatedAt: string }. - 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 }
- Define
interface AdminUser extends User { permissions: string[]; department: string }and overrideroleto be exactly"admin". - Create one value for each interface. Run with
npx ts-node src/model-layers.ts. - Write
function printIdentity(e: BaseEntity): voidthat logse.idand the timestamps. Pass each of your four values to it — confirm they all type-check. - Try the inheritance edge cases. After each, read the error then revert:
- Pass a plain
BaseEntity(no email/name) into a function that takesUser. - Override
roleonAdminUserto"viewer"and try assigning an admin value.
- Pass a plain
- Stretch: add
interface Timestamps { startedAt?: string; finishedAt?: string }and createinterface 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.