Hermes Wiki
Developer/Languages/TypeScript/LanguageInternals/Fundamentals/structural-typing-and-type-narrowing

Structural Typing and Type Narrowing

Concept

TypeScript's type system is structural ("duck typed" at compile time), not nominal: two types are compatible if their shapes match, regardless of declared names or inheritance chains. A value is assignable to a type if it has at least the required members with compatible types — { name: string; age: number; extra: boolean } satisfies { name: string; age: number } even though nothing declares that relationship. This is the opposite of Java/C#/Go's nominal typing, where a type only satisfies an interface if it explicitly says so (Go's interfaces are actually structural too, which is part of why Go and TypeScript programmers tend to transfer intuition between them more easily than, say, Java and TypeScript programmers do).

Type narrowing is how TypeScript reconciles this permissive structural model with useful compile-time safety: the compiler tracks control flow and progressively refines a variable's type within a branch based on runtime checks — typeof, instanceof, in, truthiness checks, discriminated union tags, and user-defined type guards (function isX(v): v is X). Inside if (typeof x === "string"), the compiler treats x as string for the rest of that branch, without any cast. Discriminated unions are the idiomatic way to make narrowing exhaustive and safe: a shared literal "tag" field ({ kind: "circle"; radius: number } | { kind: "square"; side: number }) lets a switch (shape.kind) narrow each case fully, and never-typed exhaustiveness checks in a default branch catch a forgotten case at compile time when a new union member is added later.

Tradeoffs

Typing model Benefit Cost
Structural (TypeScript) Flexible interop — a third-party object literal satisfies your interface with no explicit implementation; great for duck-typed JS interop Two unrelated types with the same shape are silently interchangeable — a UserId and a ProductId both typed string won't be caught as a mix-up by the compiler without extra work (branded types)
Nominal (Java, C#, Rust's traits-with-impl) A type only satisfies an interface by explicit declaration — accidental structural matches never type-check More ceremony — adapting a third-party type to an interface requires an explicit wrapper/adapter, even when the shape already matches
Structural + branded types (TS workaround) Gets nominal-style distinctness for primitives (type UserId = string & { __brand: "UserId" }) while keeping structural typing everywhere else Adds a non-obvious idiom every team member has to learn; branding has zero runtime representation, so it's a compile-time-only guarantee

Narrowing itself has a related tradeoff: unknown vs. any. any opts a value out of type checking entirely (assignable to and from anything, no narrowing needed or possible); unknown is the type-safe counterpart — assignable from anything but usable only after narrowing. Using unknown at a system boundary (parsed JSON, a catch clause's error) and narrowing before use is strictly safer than any, at the cost of writing the narrowing checks.

When to use / when not to

  • Lean on structural typing deliberately for utility types and function parameters — accepting { id: string } instead of a specific class lets callers pass any shape-compatible value, including plain object literals and test fixtures.
  • Reach for discriminated unions (not optional fields + runtime if chains) whenever a value can be one of several known variants — the exhaustiveness check pays for itself the first time a variant is added and a branch is forgotten.
  • Use branded/nominal types for identifiers and units that are representationally identical but semantically distinct (UserId vs OrderId, both string) where structural typing's leniency is actually a bug magnet.
  • Don't fight structural typing by over-using classes with private fields purely to force nominal-style incompatibility — it works (private fields do make two classes structurally distinct) but is a heavier tool than a brand for a problem that's usually just "these two strings shouldn't mix."

Common pitfall

Widening after narrowing, most often through a closure or a reassignment the compiler can't track: narrowing a string | undefined inside an if block works fine, but if that narrowed value is captured in a callback defined inside the same block, TypeScript re-widens it to the original union inside the callback — because the compiler can't prove the outer variable wasn't mutated by the time the callback runs (even in cases where you know it wasn't). This shows up as a confusing "still possibly undefined" error on code that looks already-checked, and the fix is usually to copy the narrowed value into a new const before the closure, since a const can't be reassigned and the compiler trusts that.

Engineering Lens

Structural typing is the right default for a language layered on top of JavaScript, where object literals and duck-typed interop are the norm — a nominal system would fight the ecosystem constantly. The real skill isn't "structural vs. nominal" as an abstract preference, it's knowing exactly where structural typing's leniency becomes a liability (identifiers, units, security-sensitive tags) and reaching for a brand only there, rather than either ignoring the problem or reflexively wrapping everything in classes. The same judgment call recurs in code review: a PR that types two conceptually different IDs as bare string isn't wrong on day one, but it's the kind of decision that's cheap to fix immediately and expensive once fifty call sites depend on the implicit assumption that they're interchangeable.

Sources

Hermes Wiki