Lesson 1 of 9 · 20m
Structural typing: shapes matter, names don't
Why TypeScript checks shapes instead of names, what excess property checking really does, and where duck typing bites.
TypeScript's type system is structural, not nominal: two types are interchangeable if they have the same shape, no matter what they're called. That sounds academic until the first time you pass an ORM row into a function expecting User and the compiler shrugs — the row has the right fields, so it is a User. This lesson is about what that buys you and where it bites.
Names are decoration; shape is the contract
interface User { id: string; email: string; }
type Session = { id: string; email: string; expiresAt: Date };
function ban(user: User) { /* ... */ }
const s: Session = { id: "u1", email: "[email protected]", expiresAt: new Date() };
ban(s); // fine — Session has everything User needs, plus extraNo implements, no inheritance. Session works because it carries the fields ban reads. This is duck typing made compile-time, and it's why TypeScript code avoids the Java-style interface explosion: you write the minimal shape a function needs and anything that fits compiles.
The flip side: a structurally compatible impostor is not a bug in the compiler, it's a bug in your model. If ban really needs "a row from the users table," an interface named User doesn't guarantee that — any object with id and email passes. For security-relevant code (authorization decisions, permission checks), don't let "it has the right fields" stand in for "it came from a trusted source." Validate provenance at the boundary; types only prove shape.
Fresh object literals are checked harder
ban({ id: "u2", email: "[email protected]", admin: true }); // error: 'admin' does not exist
const sneaky = { id: "u2", email: "[email protected]", admin: true };
ban(sneaky); // no errorFresh literals get excess property checking because a stray key usually means a typo. Assign through a variable and the check relaxes — the extra key is now just extra. This asymmetry trips everyone once. It also means you can smuggle unexpected fields through indirection, so don't treat an interface as proof that other keys are absent.
Widening, or why kind: "circle" stops being a circle
const shape = { kind: "circle", radius: 4 };
// ^? { kind: string; radius: number }Mutable properties widen to their general type because you could assign a different string later. If you need the literal:
const shape = { kind: "circle", radius: 4 } as const;
// or annotate: const shape: { kind: "circle"; radius: number } = ...as const is read-only and narrow — great for config tables, overkill for things you mutate.
Try this
Take any function typed to accept { name: string }. Call it with const user = { name: "a", role: "admin" } and note it compiles. Now pass the same object inline and watch excess property checking fire. Then change the parameter to { name: string } & Record<string, unknown> and explain to yourself which threat model each signature actually describes.
Field notes
interfaceis documentation plus shape, not a runtime tag.x instanceof Userfails at runtime becauseUserdoesn't exist at runtime — interfaces erase.- Everything here is stable since early TypeScript and still true in 5.x.
- If you need nominal identity ("this really is a
UserId, not any string"), structural typing won't give it to you — see the branding trick in the boundary lesson.