Structural vs Nominal Typing
Two answers to "is this type compatible with that one": compare the shapes, or compare the declared identities. The choice decides what a type name means, how cheap the check is, and whether a `UserId` can be handed to something expecting an `OrderId`.
Why does TypeScript accept a completely different type that happens to have the same fields, and how do I stop it?
Two different compatibility relations over the same declarations. Structural: a co-inductive comparison of fully resolved member tables, memoised, with a cycle-breaking assumption that the relation holds while it is being computed. Nominal: a lookup of a declared identity — a name, a module path, a DefId — plus the declared inheritance or implementation edges. One is a graph algorithm; the other is a hash comparison, and almost every downstream difference follows from that.
Structural assignability may be memoised, and may be assumed transitive, only if it is computed over fully resolved member tables and the cycle assumption is discharged consistently. Nominal assignability may be decided from the declaration site alone, without looking at members at all — which is what makes it cheap, and what makes the declared identity a permanent part of the public API.
Key points
- Structural compatibility compares member tables; nominal compatibility compares declared identities and edges.
- In a structural system a type name is an abbreviation for a shape and can carry no invariant; in a nominal system a name can be evidence, if construction is controlled.
- Go chose separately: named types are nominal, interfaces are structural and implicitly satisfied.
- Branding manufactures nominal identity inside a structural system by intersecting a phantom member that nothing else can produce.
- A brand costs nothing at runtime and costs one deliberate cast at the single place values are minted.
- Structural checking is a memoised graph comparison and a real share of build time; nominal checking is an identity comparison.
- Structural interfaces have implementors you have never heard of, which makes them easy to adopt and dangerous to extend.
Two questions a checker can ask
When code passes a value of type A where B is expected, the checker has to decide whether that is allowed, and there are exactly two families of answer. A structural checker asks whether A has everything B requires: the same members, at compatible types, recursively. A nominal checker asks whether A was *declared* to be a B — by being it, extending it, or implementing it. Nothing about the members enters into the second question.
The consequence people notice first is accidental compatibility. In TypeScript a { x: number; y: number } is a Point, and so is a Vector, and so is the result of an object literal somebody wrote inline in a test — because all three have the members Point requires. In Java, a class with exactly the fields of Point is not a Point and never will be, no matter how identical it looks.
The consequence that matters more is what a type name *means*. In a nominal system a name is an identity, so it can carry an invariant: if the only way to construct a ValidatedEmail is through a constructor that validates, then having one is evidence. In a structural system a name is an abbreviation for a shape, so it carries no evidence at all — anyone can produce a value with that shape without going anywhere near your constructor.
Neither answer is the better one in general, and both exist for good reasons. Structural typing is what lets a checker be retrofitted onto a language whose values were never declared — see [[gradual-typing]]. Nominal typing is what lets a type name mean something the compiler will enforce.
| Question | Structural (TS, Go interfaces, OCaml objects) | Nominal (Java, C#, Rust, Swift) |
|---|---|---|
| What makes A compatible with B? | A has every member B requires, recursively. | A was declared to be, extend, or implement B. |
| Cost of the check | A memoised graph comparison; can be a real share of check time. | A pointer or identifier comparison plus a walk up a small edge set. |
| Can a name carry an invariant? | No. Anyone can produce a value of that shape. | Yes, if construction is controlled. |
| Retrofittable onto existing values | Yes — nothing had to be declared. | No. Every participant must be edited to declare the relationship. |
| Adding a member to an interface | Breaks every existing implementor immediately. | Breaks them too, unless the member has a default. |
| Implementing a third-party interface | Automatic. If the shape matches, it matches. | Requires editing the type, or a wrapper/adapter. |
| Accidental compatibility | Common, and occasionally exactly what you wanted. | Impossible by construction. |
Go, which is both, on purpose
Go is the clearest illustration because it made the two choices separately. Named types in Go are strictly nominal: type Celsius float64 and type Fahrenheit float64 are different types, and assigning one to the other is an error even though both are a float64 underneath. That is nominal typing doing exactly the job it is good at.
Interfaces in Go, on the other hand, are satisfied structurally and implicitly. A type implements io.Reader by having a Read([]byte) (int, error) method; it never says so, and it can implement an interface defined in a package it has never imported. That is structural typing doing exactly the job *it* is good at, and it is the reason Go code can define narrow interfaces at the point of consumption rather than the point of definition.
The split is a design worth stealing conceptually: nominal for data, where identity and invariants matter, structural for capability requirements, where the consumer should be able to state what it needs without the producer having to agree in advance. Languages with only one relation approximate the other with extra machinery — traits with blanket implementations, adapter classes, branding.
1type Celsius float642type Fahrenheit float643 4func freeze(c Celsius) bool { return c <= 0 }5 6var f Fahrenheit = 327// freeze(f) // compile error: cannot use f (Fahrenheit) as Celsius8 9// Interfaces, however, are satisfied by shape and never declared:10type Stringer interface{ String() string }11 12func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", float64(c)) }13// Celsius now satisfies Stringer. Nothing was written down to say so.The two halves are independent choices. The named type prevents a unit mix-up; the implicit interface lets a consumer declare what it needs without the producer participating.
Branding: nominal typing inside a structural system
unique symbol brand is a TypeScript idiom, not a language feature: it works because the checker treats a unique symbol type as inhabitable only by the declared symbol, so no other type can produce a matching member. Flow has an equivalent using opaque types, which are a real language feature and cleaner. In Rust, Java or Go the equivalent is an ordinary single-field wrapper type and needs no idiom at all — which is the substantive difference between the two families here.The motivating case is boring and expensive. A UserId and an OrderId are both strings. Every function that takes one takes a string. Somewhere in a five-argument call, two of them get swapped, and the type checker — which has been asked whether a string is a string — says yes. The bug is a database lookup returning nothing, or worse, returning somebody else's record.
A nominal language solves this with a newtype: struct UserId(String) in Rust, a record wrapper in Java, a type Celsius float64 in Go. The wrapper has a distinct identity, so the swap is a compile error, and in most implementations the wrapper is free — a single-field struct has the same layout as its field.
In a structural system you have to manufacture the identity, and the standard technique is branding: intersect the underlying type with a phantom property that no real value has and no other type can produce. Because the checker compares shapes, adding a shape nothing else has is enough to make the type incompatible with everything else. The property is never written at runtime; it exists only so the structural comparison fails.
The cost of branding is that construction now needs a deliberate escape hatch — usually a single cast inside a validating constructor — and that the resulting type prints badly in errors and in hover text. Both are worth it at boundaries where identifiers are passed around, which is most of them. It is the same argument as [[nullability]] and [[gradual-typing]]: buy the guarantee once at the edge, and let everything inside rely on it.
1declare const brand: unique symbol2type Brand<T, B> = T & { readonly [brand]: B }3 4type UserId = Brand<string, "UserId">5type OrderId = Brand<string, "OrderId">6 7// The one place that mints a UserId, and where the validation lives:8function userId(s: string): UserId {9 if (!/^u_[0-9a-f]{16}$/.test(s)) throw new Error("not a user id: " + s)10 return s as UserId // the single deliberate cast11}12 13function load(id: UserId) { /* ... */ }14 15load(userId(input)) // fine16load("u_0000000000000000") // error: string is not assignable to UserId17load(orderId(input)) // error: OrderId is not assignable to UserIdNothing is added to the value at runtime — the emitted JavaScript passes a plain string. The brand exists solely to make the structural comparison fail, and the as inside userId is the one place the guarantee is asserted rather than derived.
What each relation costs the checker and the API
Structural checking is a graph problem. Deciding whether a large object type is assignable to another means walking the members, recursing into their types, and handling recursive types by assuming the relation holds while computing it. Real implementations memoise aggressively and still spend meaningful time here; deep generic types multiply the work, and this — together with intersections, from [[intersection-types]] — is where slow TypeScript builds come from.
Nominal checking is close to free. Two type identifiers are equal or they are not; the subtype relation is a small precomputed set of edges. That is why nominal languages can afford to check enormous programs quickly, and why their error messages are short: there is nothing to explain beyond "these are different types".
The API consequence runs the other way. In a nominal system the declared relationships are part of your public surface: adding an interface to a published class is additive, removing one is breaking, and a consumer cannot make their type satisfy your interface without editing their type. In a structural system there is no declaration to break — but adding a required member to an interface breaks every implementor at once, and nobody has to have told you they were an implementor. Structural systems have more implementors than you know about, which is precisely their strength and precisely their versioning hazard.
How it works
The steps, in the order the compiler takes them.
- The checker resolves each type to a member table: properties with types, call and construct signatures, index signatures.
- For a structural query it walks the target's requirements against the source's table, recursing into member types and applying variance rules at each position.
- Recursive types are handled co-inductively: the relation is assumed to hold for the pair currently being computed, so the walk terminates; the assumption is discharged if every other obligation succeeds.
- Results are memoised per type pair, which is what keeps the cost tolerable and what makes type identity (interning) important for performance.
- For a nominal query the checker compares declaration identifiers and walks the declared supertype and implemented-interface edges, which is a small bounded search.
- A brand adds a member whose type is inhabited only by an unexported symbol, so the structural walk fails for every source that did not obtain the value through the minting function.
- A newtype wrapper in a nominal language is a distinct declaration with its own identity; the compiler is free to give it the layout of its field, so the distinction usually costs nothing at runtime.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- Two identifiers of the same underlying type are swapped in a call; everything compiles, and the failure is a lookup returning the wrong record — often noticed weeks later as a data integrity complaint rather than as a bug report.
- An object literal with the right shape is accepted where a domain type was expected, bypassing the validating constructor entirely, so an invariant the team believed the type carried is quietly untrue for some values.
- A member is added to a widely implemented structural interface and dozens of unrelated types across the codebase stop compiling at once, including ones whose authors never intended to implement it.
- A nominal library type cannot be satisfied by a consumer's existing class, so every call site grows an adapter, and the adapters gradually accumulate their own logic.
- Build times climb as deep structural comparisons multiply in generic utility types; the profile blames type checking and the fix is to name and intern the types rather than to reduce them.
- A branded type is created with a cast at three different places instead of one; two of them skip the validation, and the type now means nothing while looking as though it means something.
When it helps
- Describing values you did not declare: JSON, DOM objects, third-party library returns — structural typing is the only relation that can say anything about them.
- Consumer-defined capability requirements: a function that needs only
{ read(): string }should not force its callers to implement a named interface. - Domain identifiers and units, where a nominal distinction (newtype or brand) turns a whole class of argument-swap bugs into compile errors at essentially zero runtime cost.
- Any invariant that construction is supposed to establish — validated emails, sanitised HTML, normalised paths — where the type is meant to be evidence rather than a shape.
When it hurts
- Structural typing where an invariant was intended: the type is satisfiable by anything with the right shape, so the invariant is a convention with a type-shaped decoration.
- Nominal typing across a library boundary you do not control, where satisfying an interface requires editing a type you cannot edit.
- Branding applied everywhere: every value needs minting, error messages fill with intersection noise, and the team starts casting to get past it, which removes the point.
- Deep structural comparison in hot generic code, where the checking cost is real and the alternative — naming the type once — is free.
What it costs
Every one of these is paid by something.
- Structural compatibility buys the ability to type values that were never declared, and pays with a name that guarantees nothing: any invariant you wanted the type to carry must be enforced somewhere else, and the checker will not tell you where it was not.
- Nominal compatibility buys cheap checking, short error messages and names that can carry evidence, and pays in ceremony: every relationship must be declared, and satisfying a third-party interface requires editing a type or writing an adapter.
- Branding buys nominal identity inside a structural system at zero runtime cost, and pays in ergonomics — an unavoidable cast at the minting site, worse hover text, and worse error messages for every type that includes the brand.
- Implicit structural interface satisfaction buys consumer-defined interfaces and pays in evolution risk: you cannot enumerate your implementors, so you cannot assess the blast radius of adding a member.
What else you could do
What a different compiler or language does instead, and when that is better.
- Nominal newtypes (Rust
struct UserId(String), Haskellnewtype, Go named types) give the same guarantee as branding with no idiom, and in Haskell's case with a specification-level promise of zero runtime representation. - Opaque types (Flow, OCaml module signatures, Scala 3 opaque type aliases) hide the underlying representation outside a module, so the distinction is enforced by the module system rather than by the shape — arguably the cleanest form of this, and available in fewer languages.
- Traits or type classes constrain by capability without either relation: a value satisfies a bound because an implementation was written for it, which is nominal in the implementation and structural in the requirement.
- Runtime validation with a wrapper object gives an invariant that survives serialization boundaries, at the cost of an allocation and an unwrap at every use — the right answer when the value crosses a process boundary and a compile-time brand cannot follow it.
See it for yourself
The flag, dump or tool that shows you this directly.
- TypeScript: hover any branded value to see the intersection;
tsc --noErrorTruncationis required to read the resulting error messages once brands are involved. tsc --generateTrace traceDirshows time instructuredTypeRelatedTo, which is the structural comparison itself — the direct way to confirm that assignability checking rather than parsing is what is slow.- Go:
go vetand the compiler both report named-type mismatches directly;go doc -allon a package shows the interfaces without showing implementors, which is itself the point. - Rust:
cargo expandon a newtype shows there is no wrapper code;rustc -Z print-type-sizes(nightly) confirms a single-field wrapper is the same size as its field. - To find out whether your branded types actually carry their invariant, grep for the escape hatch: every
as Brandoutside the minting function is a place the guarantee was asserted without evidence.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Structural typing is duck typing." Duck typing resolves at runtime and fails at the call. Structural typing decides statically, before anything runs; the resemblance is in the compatibility rule, not in when it is applied.
- "If it has the right fields, it is the right type." That is the definition in a structural system and false in a nominal one, and it is exactly why an invariant cannot live in a structural type name.
- "Branding adds a runtime tag." Nothing is emitted. The brand is a member the checker believes in and no value ever has, which is why the cast that mints one is unavoidable.
- "Nominal typing is just structural typing with extra steps." It answers a different question — declared identity rather than shape — which is why it can be decided in constant time and why it can be trusted to carry an invariant.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Some languages decide type compatibility by comparing shapes — if it has the right fields, it fits. Others decide it by comparing declared names — it fits only if it says it does. TypeScript and Go interfaces do the first; Java, C# and Rust do the second. The practical difference shows up with identifiers: in a structural system a UserId that is just a string is interchangeable with every other string, and in a nominal one it is not.
practical
Wherever two values of the same underlying type mean different things — user ids and order ids, cents and dollars, seconds and milliseconds — give them distinct types. In Rust or Go that is a one-line wrapper. In TypeScript it is a brand: intersect the base type with a phantom unique symbol property, and mint values through exactly one function that does the validating and contains the only cast. The discipline that makes this work is that there is *one* cast. Every additional as Brand sprinkled elsewhere is a place the guarantee was asserted rather than checked, and grepping for them is the quickest audit of whether the branded types in a codebase mean anything.
advanced
The two relations differ in a way that matters for compiler engineering, not just for API design. Structural subtyping over recursive types is a co-inductive relation: to decide whether A <: B where both mention each other, you assume the pair is related, compute the obligations, and accept if nothing contradicts. That is sound, and it means the memo table has to distinguish "known" from "assumed while computing", or a cycle can prove itself. Nominal subtyping has none of this: the relation is an explicitly declared finite edge set and can be precomputed into a bit vector or an interval labelling, which is why nominal languages can answer subtype queries in constant time and structural ones cannot. When people observe that a structural checker is slow, this is usually what they are observing — and the mitigations, interning types and memoising aggressively, are attempts to make the graph problem look more like the identity comparison.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
private and protected members make classes incompatible unless they originate from the same declaration, and unique symbol types are inhabited only by their own declaration. So "TypeScript is structural" is true as a default and false in specific, useful places — which is what the branding idiom exploits.#[repr(transparent)] and generally for single-field structs, GHC by specification for newtype, the JVM not at all before value types). Java's record wrapper is a real object with a real allocation, so the "newtypes are free" intuition transfers to Rust and Haskell and does not transfer to the JVM.If you were asked this in an interview
- A function takes
(userId: string, orderId: string)and callers keep swapping them. Fix it in a structural language and in a nominal one, and say what each costs. - Go has both nominal and structural typing in the same language. Where is each used, and why is that split defensible?
- Why is structural subtyping over recursive types harder to implement than nominal subtyping?