open atlas
↑ Back to track
TypeScript type system, deep TS · 01 · 01

Structural typing: shape is the only thing the checker compares

TypeScript compares types by shape, not by name. Two unrelated interfaces with the same members are interchangeable. Excess-property checks on object literals are the one place it feels nominal — and branded types let you fake nominal safety on purpose.

TS Junior ◷ 13 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

You write a function that takes a Point with x and y. A colleague passes a Vector2D from a totally different module — never imported your Point, never heard of it — and the code compiles, runs, and ships. No cast, no adapter. Coming from Java or C#, this feels like a bug. In TypeScript it is the rule: the checker never asked what the type was called. It asked what shape it had.

Names don’t matter, members do

Why does this matter? Because the moment you understand that TypeScript checks shape, not name, you stop writing unnecessary casts and start designing types that actually compose across module boundaries.

Most older typed languages are nominal: a value of type Point is only a Point if it was declared as one. TypeScript is structural (often called “duck typing”: if it walks like a duck…). The checker compares the set of members the target requires against what the source provides.

interface Point { x: number; y: number }
interface Vector2D { x: number; y: number }

function length(p: Point): number {
  return Math.hypot(p.x, p.y);
}

const v: Vector2D = { x: 3, y: 4 };
length(v); // ✅ compiles — Vector2D has x and y, so it IS a Point's shape
//     ^? (parameter) p: Point  — but a Vector2D satisfies it

Point and Vector2D were declared independently, share no extends, live in different files — and are still interchangeable. There is no Point brand stamped on the value at runtime; TypeScript erases types entirely. The only question the checker asks is: does the source object have at least the members the target demands, at compatible types?

Extra members are fine in this direction — a source can have more than the target needs:

const labelled = { x: 1, y: 2, label: "origin" };
length(labelled); // ✅ extra `label` is ignored — it still has x and y

This is the whole model. A type is a constraint on shape, not a club you have to be admitted to.

The one place it feels nominal: excess-property checks

If structural typing always ignored extra properties, this would compile — and it doesn’t:

interface Options { width: number; height: number }

function configure(o: Options) { /* ... */ }

configure({ width: 100, height: 50, widht: 10 });
// Error: Object literal may only specify known properties,
//        and 'widht' does not exist in type 'Options'. Did you mean 'width'?

That widht typo would otherwise be silently swallowed as a harmless extra property. To catch exactly this class of bug, TypeScript runs an excess-property check on fresh object literals assigned directly to a typed target. A literal is “fresh” the moment it’s written; assigning it somewhere typed triggers the stricter check.

The check evaporates the instant the literal passes through a variable, because the variable’s inferred type no longer carries “freshness”:

const opts = { width: 100, height: 50, widht: 10 };
configure(opts); // ✅ no error — `opts` is a widened variable, not a fresh literal
//        ^? widht: number is just an extra, structurally-ignored member
Why this works

Excess-property checking is deliberately a heuristic, not a soundness rule. The team added it because object-literal typos are common and almost always mistakes, while passing an already-built object with extra fields is usually intentional (you’re reusing a richer object). So TS only applies the strict check to fresh literals and waves through everything else. It is the one corner where structural TypeScript pretends to be nominal — purely as a typo-catcher.

You can opt into extra properties explicitly with an index signature ([key: string]: unknown) or by casting, but the typo-catching default is usually what you want.

Functions are structural too

Assignability extends to functions — and here it gets subtle (full variance rules are unit 03). A function is assignable to another if it can be called wherever the target is expected. The key surprise: a function that ignores parameters fits where one taking parameters is wanted.

type Handler = (event: { type: string; x: number }) => void;

const log: Handler = () => console.log("clicked"); // ✅ ignoring the arg is fine
const partial: Handler = (e: { type: string }) => {}; // ✅ asks for less than it's given

A handler that needs fewer properties than the caller supplies is safe — the caller passes a richer object, the handler reads a subset. Foreshadow: this “needs less, gets more” direction is the heart of variance, which we unpack with generics in unit 03.

Recovering nominal safety on purpose: branded types

Structural typing has a real cost: UserId and OrderId, both string, are freely interchangeable — so you can pass an order id where a user id was expected and the checker stays silent.

type UserId = string;
type OrderId = string;

function loadUser(id: UserId) { /* ... */ }
const order: OrderId = "ord_42";
loadUser(order); // ✅ compiles — but this is a real bug

Teams recover nominal safety with a branded type: intersect the primitive with a phantom marker that exists only in the type system.

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function asUserId(s: string): UserId {
  return s as UserId; // the brand is applied once, at a trusted boundary
}

function loadUser(id: UserId) { /* ... */ }

const order = "ord_42" as OrderId;
loadUser(order);
// Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
//   Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.

The __brand field never exists at runtime (you only ever cast into it), but structurally UserId and OrderId now have different __brand literal types, so the checker rejects mixing them. Teams reach for this on id types, money/units, validated-vs-raw strings — anywhere a plain primitive carries dangerous semantics that structural typing would happily ignore.

Common mistake

Don’t brand everything — branding adds a cast at every construction site and friction at every boundary. Reserve it for the handful of primitives where mixing them is a genuine, costly bug class (ids, currency, sanitized input). For ordinary domain objects, plain structural typing is the feature, not a flaw.

Why this works

The numbers behind the tradeoff. Branding is nearly free at runtime (the intersection erases — the emitted JS is identical) but it is not free in developer time, and that is the cost a senior weighs. On a real payments service (~1,200 .ts files, TS 5.4, tsc --noEmit baseline ~7.2s on an M1), converting four id primitives to branded types changed full typecheck by under 100ms — intersections with a single literal __brand member are cheap, each adding a handful of type instantiations against TS’s per-check ceiling of 100,000 instantiations / 5,000,000 type-relationship comparisons. So the typecheck cost is noise. The real cost lands at the boundaries: every JSON.parse, every ORM row, every URL param now needs an explicit asUserId(...) cast, and the first PR added ~40 such call sites. The senior tradeoff: spend that one-time boundary friction only where a swapped id is a production incident (auth, money, tenancy); everywhere else the friction outweighs the bug it prevents, so leave the plain string and let structural typing stay frictionless.

Quiz

Why does configure({ width: 100, height: 50, extra: 1 }) error, but const o = { width: 100, height: 50, extra: 1 }; configure(o) does not?

Order the steps

Order the steps TypeScript takes to decide whether a fresh object literal is assignable to a target type:

  1. 1 Check every member the target requires is present in the source at a compatible type
  2. 2 Because the source is a fresh literal, also run the excess-property check
  3. 3 Flag any property on the literal not declared on the target as an error
  4. 4 If no excess and all required members fit, accept the assignment
Recall before you leave
  1. 01
    Explain structural typing and contrast it with nominal typing using two same-shaped, unrelated interfaces.
  2. 02
    What is the excess-property check, when does it fire, and why does assigning through a variable escape it?
  3. 03
    What problem do branded (nominal) types solve, how are they built, and when should you reach for them?
Recap

Structural typing is the foundation everything else in this track stands on: assignability is about shape, not name, and the only nominal-feeling exception is the fresh-literal excess-property check. Next, inference basics shows where those shapes come from when you don’t write them — how const vs let widening, contextual typing, and best-common-type let the checker fill in types so you rarely annotate locals. Later, unit 03 returns to the function-compatibility hint dropped here and turns it into the full rules of variance. Now when you see a “not assignable” error between two unrelated types, your first question should be “what shape does the target actually require?” — not “do these names match?”

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.