Skip to content

Structural Typing

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 — compatible

Point 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 fine

user 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"]
Structural compatibility is about required shape

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 TS2353
interface 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 literal

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

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 — compatible
console.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 TS2322
class 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
Open in TS Playground

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.

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 TS2345
type 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.

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"]
Structural typing asks one question; two tools opt out of it
ApproachBenefitCost
Plain structural shape (no brand, no private)Maximum flexibility — any matching shape works, from any sourceTwo conceptually different values with the same primitive shape (a UserId and an OrderId, both string) are freely interchangeable
private member on a classRecovers nominal-style distinctness for classes with zero extra typesOnly 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 costBoilerplate per branded type, and something has to actually construct a branded value safely (covered in Typing APIs & Boundaries)
  • 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/protected members change that.
  • A branded type is a real runtime wrapper, like a value object class. It’s purely a compile-time fiction — __brand never 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 via as.
  • 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.

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 Cart and a Wishlist that happen to both have items: 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.

What does "structural typing" mean?
Can a value with extra properties be assigned to a type that requires fewer?
Why does assigning { width: 10, height: 20 } directly to Options { width: number } error, but assigning it via a variable does not?
What technique gives you nominal-style distinctness in a structural system?
Does this compile?
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;
Adding an identical private secret = 1; field to both Point2D and Coordinate above changes the result. Why?
Why does this fail to compile, even though both are ultimately strings?
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId) {}
declare const orderId: OrderId;
getUser(orderId);