Structural Typing
Shape, not name
Section titled “Shape, not name”Many languages (Java, C#) use nominal typing: two types are compatible only if they share a declared name or inheritance link. TypeScript uses structural typing: two types are compatible if their shapes match, regardless of names.
interface Point { x: number; y: number; }interface Coord { x: number; y: number; }
let p: Point = { x: 1, y: 2 };let c: Coord = p; // ✅ different names, identical shape — compatiblePoint and Coord were never declared to be related, but TypeScript doesn’t care. A value that has an x: number and a y: number satisfies both. This is sometimes called “duck typing” — if it walks like a Point and quacks like a Point, it is a Point, as far as the type checker is concerned.
More properties is fine — it’s a subset relationship
Section titled “More properties is fine — it’s a subset relationship”A value can have extra properties and still be assignable to a type that asks for fewer. This follows directly from the sets model: the set of “objects with x, y, and z” is a subset of “objects with x, y.”
interface Named { name: string; }
const user = { name: "Ada", age: 36 };const n: Named = user; // ✅ user has name (plus extra) — that's fineuser has everything Named requires (a name), so it fits. The extra age is invisible to anyone holding it as a Named.
flowchart LR
val["value:
{ name, age, email }"] -->|has everything
Named requires| target["target type:
Named { name }"]
target -->|assignable ✅| ok["accepted"] The exception: excess property checks on object literals
Section titled “The exception: excess property checks on object literals”There’s one place TypeScript tightens up. When you assign an object literal directly, it flags properties the target doesn’t know about — because a literal with unexpected keys is almost always a typo or mistake.
// @expect-error TS2353interface Options { width: number; }
const a: Options = { width: 10, height: 20 };// ❌ Error: 'height' does not exist in type 'Options'.
const raw = { width: 10, height: 20 };const b: Options = raw; // ✅ no error — not a fresh literalAssign the same object through a variable and it’s allowed, because now it’s the ordinary structural rule (extra properties are fine). Excess property checking is a deliberate, narrow safety net for the common “I typed the option name wrong” bug — not a change to the underlying model.
Why this matters
Section titled “Why this matters”Structural typing is why TypeScript feels so fluid with plain objects and why you rarely need to declare that one type “implements” another. It’s also why two libraries with their own Point types just work together. The cost is that unrelated types with the same shape are interchangeable — occasionally you want nominal distinctness (a UserId that isn’t just any string), which you fake with branded types, previewed below.
Classes are structural too — until private gets involved
Section titled “Classes are structural too — until private gets involved”Structural compatibility isn’t limited to interface/type — it applies to classes as well. Two classes that were never declared to be related are still interchangeable if their shape matches:
class Point2D { constructor(public x: number, public y: number) {}}class Coordinate { constructor(public x: number, public y: number) {}}
const p = new Point2D(1, 2);const c: Coordinate = p; // ✅ unrelated classes, identical public shape — compatibleconsole.log(c.x, c.y);Point2D and Coordinate share no inheritance, no interface, nothing but coincidentally identical public fields — and TypeScript accepts one wherever the other is expected, exactly like the Point/Coord interfaces earlier. This surprises people coming from nominally-typed languages, where two unrelated classes are never interchangeable no matter what fields they have.
There’s exactly one thing that breaks this: a private member. Add one, even with the same name and type on both classes, and the classes stop being compatible:
private makes an otherwise-identical class incompatible
// @expect-error TS2322class Point2D { private secret = 1; constructor(public x: number, public y: number) {}}class Coordinate { private secret = 1; constructor(public x: number, public y: number) {}}
const p = new Point2D(1, 2);const c: Coordinate = p; // ❌ TS2322 — each class's private member is its own, even with the same name Types have separate declarations of a private property 'secret' is the actual message. TypeScript’s structural check has one nominal-flavored carve-out: a private (or protected) member is only considered the same as another class’s member if it comes from the exact same declaration (through inheritance) — never merely by matching name and type. This is the one place the shape-matching model in this lesson stops applying, and it’s covered in full once classes are introduced.
A branded type, concretely
Section titled “A branded type, concretely”The “occasionally you want nominal distinctness” problem above has a standard structural-system fix, and it’s worth seeing once here even though the full treatment is later: intersect a primitive with a marker property that doesn’t actually exist at runtime.
// @expect-error TS2345type UserId = string & { readonly __brand: "UserId" };type OrderId = string & { readonly __brand: "OrderId" };
function getUser(id: UserId) {}
declare const orderId: OrderId;getUser(orderId);error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'. Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'. Types of property '__brand' are incompatible. Type '"OrderId"' is not assignable to type '"UserId"'.Both UserId and OrderId are, underneath, plain strings — at runtime __brand doesn’t exist on either value, it’s purely a compile-time marker. But because the marker’s literal value differs ("UserId" vs "OrderId"), the two intersections are no longer structurally identical, so the compiler now treats them as distinct — even though a plain string would satisfy neither on its own without an explicit assertion. This is the escape hatch for the exact gap structural typing opens up: reach for it when two values happen to share a primitive’s shape but must never be substituted for each other. Typing APIs & Boundaries (Practical Mastery) covers constructing and validating branded values properly.
Mental Model
Section titled “Mental Model”Structural typing asks one question, always: “does this value have everything the target shape requires?” Interfaces, type aliases, and classes are all judged by that same question — extra properties never hurt, and unrelated declarations with the same required shape are simply the same type as far as assignability cares. private is the single, deliberate exception: it makes membership depend on where a property was declared, not just what it looks like, which is why it’s the one tool that recovers nominal-style distinctness on a class. A branded type is the same trick without a class at all — a marker property whose only job is to make an otherwise-identical shape fail the “everything required” check against a same-shaped-but-conceptually-different value.
flowchart TB q["Does the value have every required property?"] -->|yes| ok["Assignable — regardless of declared name or relationship"] q -->|"private member present"| nominal1["Membership now depends on WHERE it was declared"] q -->|"branded marker property"| nominal2["Shapes differ by a compile-time-only marker"]
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
Plain structural shape (no brand, no private) | Maximum flexibility — any matching shape works, from any source | Two conceptually different values with the same primitive shape (a UserId and an OrderId, both string) are freely interchangeable |
private member on a class | Recovers nominal-style distinctness for classes with zero extra types | Only works for classes; two structurally-open interface/type shapes get no such protection |
Branded type (string & { __brand: ... }) | Works for any primitive, not just classes; zero runtime cost | Boilerplate per branded type, and something has to actually construct a branded value safely (covered in Typing APIs & Boundaries) |
Common Misconceptions
Section titled “Common Misconceptions”- Classes in TypeScript are nominally typed, like Java or C#. They’re structural by default, exactly like interfaces — two unrelated classes with the same public shape are interchangeable. Only
private/protectedmembers change that. - A branded type is a real runtime wrapper, like a value object class. It’s purely a compile-time fiction —
__brandnever exists on the actual value. The safety is 100% at the type-checker level; nothing stops a raw string from being force-cast to a branded type viaas. - Excess property checks mean TypeScript is secretly nominal after all. They’re a narrow, deliberate typo-catcher on fresh object literals only — the underlying model is still “does the shape fit,” which is why the exact same object assigned via a variable is accepted with no complaint.
Reading AI-generated code
Section titled “Reading AI-generated code”Generated code that threads plain string/number values through many functions is the most common place structural typing’s flexibility becomes a liability, because nothing stops the wrong-but-same-shaped value from being passed. Check for:
- Multiple identifiers of the same primitive type flowing through nearby parameters —
userId: string,orderId: string,sessionToken: string— with no branding. A typo’d argument order compiles clean every time. - A lookup keyed by one kind of ID that’s fed a variable whose name suggests a different kind of ID, with nothing but a variable name to tell them apart.
- Two classes or object shapes treated as interchangeable “because the fields matched,” in a context where the two things they represent are conceptually different (a
Cartand aWishlistthat happen to both haveitems: Item[]).
🐛 Spot the bug
interface User { id: string; name: string }const users: Record<string, User> = { u1: { id: "u1", name: "Ada" } };
function getUserName(userId: string): string {return users[userId].name;}
const orderId = "o1"; // actually an order id, not a user id — both are just `string`console.log(getUserName(orderId).toUpperCase());Show the answer
This compiles with zero errors and crashes at runtime. getUserName takes a plain userId: string, and orderId is also a plain string — structurally (in fact, primitively) identical, so the compiler has no basis to object even though the two represent completely different kinds of ID in the domain. users["o1"] is undefined (no user has that id), so .name throws TypeError: Cannot read properties of undefined (reading 'name'). Branding UserId and OrderId as distinct types (this lesson’s stub) would have turned this into a compile-time TS2345 at the call site — the exact class of mistake structural typing’s flexibility cannot catch on its own.