Tags: web-dev concept

Structural Typing

Date: 2026-09-28


A TypeScript type is a set of values, and “is A assignable to B” means “is every A also a B”. Shape decides membership, never the name — so an object with extra properties still fits, and the only place TypeScript objects to extras is a fresh object literal.


Structural typing means two types are compatible when their shapes are compatible, regardless of what they’re called or where they were declared. The nominal-versus-structural axis itself is in Type Systems; this note is the model TypeScript runs on.

Types as sets

The model that makes every compatibility rule predictable: a type names the set of values that could inhabit it. Assignability is the subset test.

type             the set of values
────────────     ──────────────────────────────────────
never            ∅ — no values at all
'gbp'            { 'gbp' }
'gbp' | 'eur'    { 'gbp', 'eur' }
string           every string
{ id: string }   every object with a string `id` —
                 AND WHATEVER ELSE IT HAS          ← the one people miss
unknown          everything

Read off the table:

  • Adding properties to an object type makes the set smaller, not larger. { id: string; email: string } is a subset of { id: string } — which is why the wider-looking type is assignable to the narrower-looking one
  • Adding members to a union makes the set larger. 'gbp' | 'eur' accepts more than 'gbp'
  • never is assignable to everything (the empty set is a subset of every set) and everything is assignable to unknown. any isn’t in the table because it isn’t a set — it switches the test off in both directions

Extra properties are fine — mostly

type Customer = { id: string }
 
const row = { id: 'c_1', email: 'a@b.com', ltv: 420 }
const c: Customer = row                       // ✓ row is in the set
 
const c2: Customer = { id: 'c_1', email: 'a@b.com' }
//                                ~~~~~ ✗ Object literal may only specify known properties

Same value, different verdict. Excess property checking is a separate lint-like rule that applies only to a fresh object literal assigned straight to a typed target. The reasoning: a literal written inline with an unknown key is almost always a typo (colour for color), whereas a variable passed along probably has extras for good reason.

Notice what this means: the typo protection vanishes the moment the literal goes through a variable first. It’s the one place TypeScript behaves nominally-ish, and it’s shallow.

Where the model bites

Units and IDs collapse. type OrderId = string and type CustomerId = string are the same set, so passing one for the other compiles. The fix is branding — intersecting with a phantom property so the sets differ — covered with its syntax in Type Systems. Worth it for money, IDs and units; ceremony everywhere else.

Functions compare the other way round for parameters. A function accepting Customer | Guest can stand in where one accepting Customer is expected — it handles more, so it’s safe. That’s contravariance: parameter sets must be supersets.

expected:  (c: Customer) => void
supplied:  (c: Customer | Guest) => void     ✓ handles everything expected and more
supplied:  (c: VipCustomer) => void          ✗ would be handed non-VIPs

The catch: strictFunctionTypes enforces this only for function-type properties. Methods declared with method syntax (save(c: Customer): void) stay bivariant — the unsafe direction compiles — a deliberate hole kept so that arrays and DOM (Document Object Model) types remain usable.

Classes are structural too. An object literal with the right shape satisfies a class type, instanceof checks notwithstanding. The exception: a class with a private or #private member only accepts instances from that declaration — the one genuinely nominal escape hatch in the language.

Why it’s the right default for JavaScript

  • JS code is duck-typed already. Structural types describe what the code actually requires, rather than imposing a class hierarchy it never had
  • Types from different libraries interoperate without adapters — anything with { x: number; y: number } is a point
  • The cost is paid at the edges: accidental compatibility between things that happen to share a shape. Branding covers the cases where that matters

Related: Generics for types that vary by parameter, Type Narrowing for how a union’s set shrinks inside a branch, Type Safety Boundaries for where the set claimed and the value received part company.