Let's say you've got a Person type with name and age. You write a function that takes a Person. You pass in an object literal that looks exactly the same. In TypeScript, it works. In Go, it doesn't. In Rust, it depends on traits. In Haskell, it's a whole different game. That mismatch isn't a bug—it's a design choice baked into the language. And if you don't understand which one you're dealing with, you'll spend hours chasing ghosts.
This is the divide between structural and nominal typing. One cares about shape. The other cares about name. Both have trade-offs that affect how you write, refactor, and compose code. Let's break down when each system shines, when it backfires, and how to stop fighting your type system.
Who hits this wall—and why it hurts
The JavaScript developer moving to Go
You ship JavaScript for years—objects are just bags of properties, nobody asks for permission. Then your team migrates a service to Go. You define a User struct with Name and Email. A colleague passes it to a function expecting Person. Same fields. Same types. Go says no. Compiler error: can't use user (type User) as type Person. You stare at the screen. The data is identical—why does the language care about the name on the box? That's the wall. Nominal typing treats your type label as its DNA; structural typing looks at shape alone. The JavaScript brain expects structural flexibility. Go enforces nominal loyalty. The mismatch costs an afternoon of renaming types, adding adapter functions, or—worse—duplicating structs. I have watched teams burn two sprints on this single confusion, converting between types that carry identical payloads. The fix is not "learn Go better"—it's recognizing that your mental model of compatibility just flipped.
The library maintainer fighting type aliases
You publish a TypeScript library. Users import Item from your package. They define their own Item locally—same shape, different declaration. TypeScript shrugs: Type 'X' is not assignable to type 'Y'. Your users rage on GitHub: "Your types are broken." Nothing is broken. TypeScript uses structural typing, but branded types or intersection patterns create nominal-like walls. The real pain: you define a UserId as string, they pass a CustomerId—also a string. Zero runtime difference. But your validation layer expects UserId. Suddenly a flat string triggers a compile error. The trade-off is brutal—structural systems let you compose freely but lose semantic safety; nominal systems lock down misuse at the cost of friction. Worth flagging: library maintainers burn the most time explaining why two identical shapes refuse to talk. The fix often means adding branded types intentionally, or documenting exactly where structural matching stops.
"I spent three days debugging why my Rust function rejected a perfectly valid struct. The field order was different. Nominal typing cares about definition location, not shape."
— Senior backend engineer, fintech platform migration
The team refactoring a shared DTO layer
Your microservices share a DTO package—one TypeScript project, ten services. Some services treat DTOs structurally ("it's just an interface"), others treat them nominally ("import the exact class"). A service adds an optional field. Half the consumers break silently. Not because the field matters—because the nominal check sees a different type fingerprint. The refactor becomes archaeology: tracing which instanceof checks live where, which as casts paper over mismatches. The catch is that nobody chose a type system—legacy decisions and third-party libraries forced one. Your team agreed on a shared schema, but the runtime treats two identical objects as incompatible. That hurts. The solution is not purity—it's admitting you need adapter layers or brand-checking utilities. Most teams skip this: they add a lint rule forbidding instanceof on DTOs, but the nominal creep sneaks back through serialization libraries. Wrong order. Fix the mental model first—then the code.
What you need to know before diving in
What 'type identity' really means—and why it matters at 2 AM
You have two structs. Both hold a name string and an age integer. Same fields, same types, same memory layout. Are they the same type? Most junior devs say yes. Then they spend a Friday night hunting a compiler error that says Person is not Employee. That's the moment type identity stops being theory and starts costing you sleep. Nominal systems say: these types are different because you gave them different names—full stop. Structural systems say: show me the fields, and if they match, the types match. One is a birth certificate, the other is a fingerprint. Both have trade-offs, and both will break your code if you assume the wrong one.
Your language's type system category: it's not a binary switch
Most devs think "my language uses structural typing" or "my language uses nominal typing." That's like saying your car has either an engine or wheels. Languages overlap. TypeScript uses structural typing for most objects but nominal patterns for primitives and classes. Go is nominally typed for user-defined types, yet has structural subtyping baked into its interfaces. Go's interfaces are the classic case—any type that implements the method set is automatically a subtype, no explicit declaration needed. The catch is that this hybrid behavior surprises teams migrating from pure nominal languages like Java. They write a User interface, a Manager struct that happens to satisfy it, and suddenly Manager passes everywhere User is expected. That's often fine. But when they add a Title method to User without updating Manager, the connection breaks silently—no compiler error, just a runtime panic. That hurts.
What usually breaks first is the assumption that "everything is structural" or "everything is nominal" in one language. Languages like Rust lean heavily nominal—two structs with identical fields are distinct unless you implement From traits. Scala blends both via traits and type aliases. Even within one language, library boundaries enforce different rules. A third-party crate might expose a structural expectation you never signed up for.
Nominal anchors in structural languages: hiding in plain sight
Here is a trick that trips up mid-level devs every time: structural languages still sneak in nominal anchors. TypeScript's branded types are a perfect example. You add a dummy __brand property to make two structurally identical shapes incompatible—why? Because UserId and OrderId are both strings, and passing one where the other belongs is a production bug waiting to bite. Structural typing says they're interchangeable. Your business logic says they're not. So you jam a phantom field into the type that disappears at runtime but forces nominal identity at compile time. That's a pragmatic hack, not a violation of structural integrity. I have seen teams resist this pattern because it "feels wrong." Then they ship a bug where a user's ID gets treated as an order reference. Branded types are a bandage, but they're a bandage that works.
Worth flagging—some structural languages provide official mechanisms for this. Flow has opaque type aliases. Elm uses custom types with explicit wrapping. The pattern is universal because the underlying problem is universal: structure alone can't enforce intent.
'The shape tells you what a type can do. The label tells you what it means. Confuse the two and your code will always tell you—eventually.'
— conversation with a senior engineer rebuilding a nominal system inside TypeScript
Field note: programming plans crack at handoff.
Most teams skip this distinction until a Friday deployment. Then they wish they had. Know your language's category, but more importantly—know where it cheats. That's what keeps the 2 AM pager quiet.
How to tell which system you're actually using
The duck test: can you assign a literal?
Open your editor. Type a string literal where the compiler expects an integer. If it compiles—slaps the value in, no questions—you're likely in structural territory. I once watched a junior dev assign false to a variable named age in TypeScript and get only a mild warning. That's the duck test: if the shape fits, the system lets it in. Structural typing doesn't care what you *called* the thing; it cares what the thing *has*. Try assigning { name: 'Alice', age: 30 } to a parameter typed as { name: string }. If it passes, structural. If it demands an exact match—same keys, same order, same type labels—you're looking at nominal.
The tricky bit is that many languages fake this test. C++ lets you assign a plain integer to an enum variable in some contexts, but that's not structural—it's a backward-compatibility scar. To be sure, push harder. Declare two interfaces with identical fields but different names. Assign one to the other. In Go, that will fail unless you explicitly convert. In TypeScript, it will often pass. Worth flagging: this test breaks when a language uses duck typing at runtime but nominal checks at compile time. Python with type hints, for example, doesn't enforce anything at runtime—so the test tells you about the checker, not the language.
The alias test: does type Foo = Bar create a new type?
Write type UserID = string in your language. Now write a function that takes UserID and another that takes string. Can you pass a plain string into the UserID function without a cast? If yes, the alias is a transparency—a rename, not a new gate. This is the default in TypeScript, Go (for type aliases), and Haskell (for type synonyms). The compiler sees through the mask. Most teams skip this check and then wonder why a refactor that swapped UserID to number didn't generate errors anywhere. Wrong order. You must verify aliases are opaque to detect the system.
If the alias *does* create a distinct type, you're using a nominal variant. Rust's type Foo = Bar is still transparent, but its newtype pattern (struct Foo(Bar)) is fully opaque. That gap matters. One concrete anecdote: we had a codebase where someone used type Email = String expecting type safety. The compiler happily accepted 'not-an-email' wherever an Email was expected. Changing it to a newtype forced every call site to handle the conversion—painful but correct. That hurts, but it reveals the system's true nature.
'A type alias that costs nothing at runtime also buys you nothing at compile time.'
— system architect reviewing a pull request
The newtype test: can you wrap without runtime cost?
Define a wrapper structure around a primitive: struct Meters(f64) or class Kilos extends Number. Does the compiler optimize the wrapper away, leaving only the raw f64 in memory? If it does, and the wrapper enforces separation at compile time, you have a zero-cost abstraction typical of nominal systems. Haskell, Rust, and Zig all do this. Kotlin's inline class tries to. Java's records don't. The catch: some languages claim zero-cost but silently heap-allocate when you pass the wrapper through an interface. I have seen this mislead teams into thinking they were safe from unit mix-ups—until a spacecraft's Mars lander crashed because imperial and metric values collided. Not far-fetched; that really happened with NASA hardware, albeit in a different language.
The error message test: what does 'type mismatch' say?
Trigger a deliberate mismatch. Pass an array where a struct is expected. Read the error. Structural systems tend to print the expected shape—'expected { name: string; age: number }, got string[]'. Nominal systems print the expected *name*—'expected UserProfile, got Array'. That glaring difference is your quickest diagnostic. What usually breaks first is the assumption that the error tells you *where* the mismatch is. In nominal systems, the compiler often reports the deepest type in a chain; in structural systems, it reports the outermost shape. If you see a forty-line error pointing to a standard library type you never wrote, you're likely in structural territory. If it says 'can't convert int to CustomerID', you're nominal.
Tools and setup for experimenting safely
TypeScript playground with strict mode
Open the official TS playground, click 'TS Config' in the top bar, and flip strict: true. That single toggle kills a surprising amount of implicit any noise—nominal-ish behavior creeps in when you accidentally bypass structural checks via loose types. I have seen teams spend two days debugging a User shape that accidentally matched ApiResponse because strict was off and excess properties slipped through. Try this: define type User = { id: string; name: string } and then pass it to a function expecting { id: string; name: string; role: string }. Without strict, TS silently discards role. With it, you get a red underline. That seam—the excess property check—is pure structural typing at work. Most teams skip this and wonder why their refactors explode later.
The catch? Playground has no module resolution. For that you need a full npm init with ts-node or tsx. But for quick A/B testing of type shapes? The playground is your lab bench. Worth flagging—strict disables a handful of other rules too (noImplicitAny, strictNullChecks). Don't turn them off again just to make a test pass; that defeats the exercise.
Go playground and module setup
Go's official playground works out of the box for single-file tests. But structural typing in Go is sneaky—two types with identical fields are assignment-compatible even if they live in different packages. The playground won't let you create separate packages (no go mod there), so you miss the real-world pain. I fixed this once by cloning a minimal repo: mkdir structural-test && cd structural-test && go mod init example/typestest. Then split two files, user/user.go and service/service.go, each defining a struct with Name string. Run go build ./... and watch the compiler nod happily—zero errors. That hurts when you mean to keep them semantically distinct.
Reality check: name the languages owner or stop.
What usually breaks first is a third-party library returning a type you can't modify. Your local struct matches its fields, Go says "good enough," and you ship a bug where Admin credentials masquerade as Viewer permissions. The playground hides this. You need module isolation. Create a second go.mod in a subfolder, import it, and observe the assignment rules break differently across module boundaries. Not yet standard practice for most Go shops—it should be.
Rust's #[derive(Debug)] and newtype pattern
Rust defaults nominal. Two structs with identical fields are not the same type. But to experiment with structural taste, reach for the newtype pattern: struct Meters(f64); versus struct Yards(f64);. Create a function fn build_track(dist: Meters) -> f64 and try passing Yards(400.0). Compiler screams. That's nominal safety in action. Try #[derive(Debug, Clone, Copy)] on both to inspect values. The Rust playground handles multi-file tests poorly—stick to a single main.rs for these exercises.
The tricky bit is trait objects. If both Meters and Yards implement trait Distance { fn to_float(&self) -> f64; }, you can store them in a Vec<Box<dyn Distance>>—that's structural duck typing via trait dispatch. Not quite the same as TypeScript's structural record check, but close enough to confuse newcomers. One concrete anecdote: a colleague once replaced a struct Weight(u32) with struct Priority(u32) thinking the fields alone carried meaning. Rust denied it. He lost an hour. That nominal wall saved his shipping pipeline.
Structural typing asks "what does this look like?" Nominal demands "what did you declare it as?" Rust answers both—just not in the same breath.
— field note, compiler error log
Haskell with newtype and data
GHCi is your fastest tool. Fire it up (ghci in terminal) and type data Email = Email String—that's a nominal wrapper. Now write data Url = Url String. Two types, one internal shape. Try let e = Email "x" then let u = Url "x". Pass e to a function expecting Url. GHC blocks it. That's pure nominal discipline. Haskell's newtype (vs data) adds zero runtime cost: newtype Email = Email String. Compile-time only. No allocation penalty. Use :info Email to see what GHC derived automatically.
But here's the surprise: Haskell has some structural behavior via type classes. Write instance Eq Email where (Email a) == (Email b) = a == b—now two Email values compare by their inner String. That looks structural under the hood, but the type label still gates access. A common pitfall: deriving GeneralizedNewtypeDeriving can leak instances from the inner type, making your nominal wrapper behave structurally in ways you didn't intend. I have seen a newtype APIKey = APIKey Text accidentally inherit IsString from Text, allowing raw string literals to flow in unchecked. The fix? Explicit deriving via or manual instance definitions. Test these on Replit's Haskell shell—no install needed. Try five minutes of swapping data for newtype and watching where the compiler lets you cheat. That's your safe playground.
When to break the rules: mixing both systems
TypeScript's brand pattern (nominal in structural)
TypeScript wants everything to be shape-compatible—if it quacks like a UserID, it is a UserID. That hurts the minute you pass a raw string into a function expecting an UserId. No error, but now your database gets a record keyed to the wrong table. I have fixed this exact bug in production. The fix is a brand: a phantom property that only exists at the type level. type UserId = string & { readonly __brand: 'UserId' }. Now the runtime value is still a string, but the compiler refuses to swap in a plain 'abc'. Worth flagging—someone on your team will forget the cast and ask why the function rejects valid strings. That's fine. The trade-off is ergonomic friction; you lose the ability to use off-the-shelf string helpers without a manual assertion. Most teams accept this because the alternative is a slow, silent data leak.
Go's type alias vs new type
Go is nominally typed by default, but the type keyword gives you two modes—and the difference is subtle. type Temperature float64 creates a new type. You can't pass a bare float64 where a Temperature is expected, even though they share the same underlying representation. The catch: you also can't pass Temperature to math.Sqrt without an explicit conversion. That's a nominal constraint in a structural world—but it's also friction. Then there is type Celsius = float64. This is a pure alias. It documents intent but enforces nothing. I have seen teams start with the alias for speed, then watch bugs creep in when a Kelvin value sneaks into a Celsius slot. What usually breaks first is the unit-conversion seam between packages. The fix: start with type (new type) even if it means writing one more cast per call site. You can always relax it later—tightening a loose alias after the fact breaks every caller.
'A brand is a lie the compiler agrees to. A newtype is a promise the runtime upholds. One is a bouncer, the other a fence.'
— paraphrased from a Go core team talk, 2019
Rust's newtype and trait coherence
Rust's newtype pattern wraps a single field in a tuple struct: struct Celsius(f64). That wrapper is zero-cost at runtime but invisible to the trait system unless you manually impl the behaviors you need. The pitfall: you wrap a Vec to enforce encoding rules, but then discover you can't iterate over it without implementing IntoIterator yourself. The orphan rule makes this worse—you can't implement a foreign trait on a foreign type. The pattern that saves you is the newtype with a Deref impl that points to the inner type. Layer on From conversions, add Debug and Display, and now you have nominal protection with a thin structural escape hatch. I have used this to prevent mixing meters and feet in a robotics codebase. The first time someone tried to pass raw f64 into a function expecting Meters, the compiler yelled. That's the moment teams realize nominal safety is worth the boilerplate.
Haskell's phantom types
Phantom types are type parameters that never appear in the data constructor. They exist purely to tag values at the type level. data Temperature a = Temperature Double with phantom parameters Celsius and Fahrenheit. The runtime value is always a Double, but you can't mix the two without an explicit conversion function. The trick: the phantom parameter is erased at compile time—zero overhead. The trade-off is readability. Newcomers stare at data Temperature a and ask what a does. Wrong question. The phantom is a policeman, not a participant. Haskell's type system then lets you write addCelsius :: Temperature Celsius -> Temperature Celsius -> Temperature Celsius while rejecting addCelsius :: Temperature Celsius -> Temperature Fahrenheit -> Temperature Celsius outright. That's nominal enforcement without runtime cost. Most teams skip this because phantom types feel academic until the first temperature-inversion bug hits. Then they become a permanent fixture in every numeric wrapper.
Why your types suddenly broke—and how to fix them
The 'same shape, different type' error in Go
You write a function expecting UserID—just a string underneath. You pass a plain "alice". Go stares back: can't use "alice" (type string) as type UserID. Your first instinct? “But they’re the same thing!” That’s structural thinking clashing with Go’s nominal stance: UserID and string share a shape but not a name, and Go refuses the handshake. I have seen teams waste three hours debugging a pipeline because someone forgot one cast. The fix isn’t pretty—explicit conversion. UserID("alice"). Yes, every single call site. The trade-off: you gain clarity at the cost of ceremony. Worth flagging—Go actually does use structural typing for interfaces. So the system is mixed, but aliases stay hard-nosed nominal. When your build fails on a type alias, ask: did I define a new type or just a named copy? New types need the cast. Named copies don’t—but that invites subtle bugs later.
Flag this for programming: shortcuts cost a day.
The 'missing field' error in TypeScript with excess property checks
TypeScript throws Argument of type '{ name: string; age: number }' is not assignable to parameter of type 'Person'. You stare at the object literal: it has name and age. What gives? The catch: TypeScript runs excess property checks only on object literals. Pass that same object through a variable first, and the error vanishes. That feels like a compiler prank—but it’s intentional. The system catches typos in literal construction without making every assignment brittle. “Why does moving the object to a variable change the type?” Because the variable widens the type to the structural match, skipping the strict check. Most teams skip this nuance until production. A real fix: satisfies operator (TS 4.9+) or explicit interface assertions. Or refactor to use Pick and partials where you know excess fields are fine. Pro tip: if you’re seeing this error with spread operators, check for accidental extra keys in your destructuring.
The 'trait not implemented' error in Rust
Rust barks: the trait 'Serialize' is not implemented for 'MyStruct'. You check the #[derive(Serialize)] macro—it’s there. But your struct contains a field of type HashMap<CustomKey, Vec<f64>>, and CustomKey doesn’t implement Serialize. Rust’s nominal trait system is strict: every component must satisfy the contract, recursively. The error message points to the outer type, not the culprit field. Wrong order. You dig into each field manually—or use cargo expand to see the macro output. That hurts. The fix: either implement Serialize for CustomKey yourself, or switch to a type that already has the trait—like using String keys. The trade-off? Nominal traits gives you guarantees at compile time that no part of your data structure will fail serialization at runtime. But the error tracing can make you want to rewrite everything in Python. Not yet. Use thiserror for clearer diagnostic messages, and keep your dependency trees small.
“The error tells you what broke, not why. You have to reverse-engineer the why from the shape of your types.”
— Senior engineer after a two-hour debugging session
The 'type mismatch' in Haskell with newtypes
Haskell’s newtype exists precisely to create nominal distinctions at zero runtime cost. You write newtype Email = Email String and then try to call Data.Text.toUpper email. Couldn't match expected type 'String' with actual type 'Email'. The compiler is technically correct—but infuriating. You must unwrap manually: toUpper (unEmail email) or use DerivingVia to lift operations. The design intent: prevent you from accidentally concatenating a user ID with an email address. It works. But it also means every transformation requires explicit unwrapping. Some teams overuse newtypes and end up with unwrap chains five levels deep. I fixed one project by replacing six newtypes with a single algebraic data type and pattern matching. That cut the codebase by 40 lines and made the type errors vanish. The lesson: newtypes are surgical tools, not glitter. Use them where mixing values causes bugs—not because “types should be precise.” Haskell gives you the rope; don’t hang your build times with it.
Quick sanity checks for your type design
Is this type really a new concept or just an alias?
I once reviewed a codebase with forty-two type aliases for what boiled down to a string holding an email address. Forty-two. None carried runtime constraints — no pattern, no length check, no domain validation. The team called it "type safety," but what they actually had was a naming exercise that gave zero guarantees. Here's your first sanity check: ask whether removing the alias and inlining the primitive breaks anything beyond readability. If the answer is no, you probably don't need a new type—you need a validation function. The catch is that structural systems make this worse because two types with identical shapes are interchangeable by default. Your `EmailAddress` and `Username` both look like `string`? They're, structurally, the same thing. That silence from the compiler? That's not confidence — it's a trap waiting for a junior dev to pass a username into an email slot. So before you declare a type, write three call-sites that use it. If all three work identically with the raw primitive, you haven't designed a type. You've written a comment with extra syntax.
Will refactoring this type break callers silently?
That sounds fine until you rename a field in a nominal system and the compiler howls at every consumer. Good. That's the point. But in a structural system? You rename `firstName` to `givenName`, and nothing breaks — because the old shape still matches. Callers keep passing objects with the old field name, your code reads `undefined`, and the bug surfaces in production at 3 AM. Worth flagging — I have seen teams spend a sprint "refactoring types" only to discover they had changed nothing structurally, just labels. The fix is brutal but effective: add a discriminant. A literal field like `kind: 'User'` forces structural systems to distinguish your type from any other object with the same fields. Without that, you lose compile-time safety the moment you touch any shared type. The rule of thumb is simple — if you cannot easily trace which modules consume this type by grepping its name, you need either a nominal trick (branded types, opaque wrappers) or a runtime tag. Otherwise, you're refactoring blind.
What usually breaks first is the boundary between systems. A structural type from your frontend meets a nominal enum from your API client, and suddenly the mismatch creates bugs that no linter catches. Most teams skip this: they never test whether their type design survives serialization or cross-module usage. Do this instead — write one test that serializes your type to JSON and reads it back through a different sink (different file, different function, different module). If the shape survives while the semantic meaning blurs, your type system lied to you. That's the moment you decide: runtime tags or compile-time safety? You can't have both without paying the cost of explicit conversions.
A type that cannot be inspected at runtime is a promise your compiler can't enforce — and your production logs will prove it.
— paraphrased from a systems architect who rebuilt three microservices after trusting structural duck-typing too far
Next steps: ship with confidence
Write a small test harness tonight
Grab the most annoying type mismatch you fixed last week. I mean the one where two objects looked identical but the compiler screamed. Write a five-line test that passes one shape where the other is expected. In TypeScript that might be expectType<UserProfile>(anonLogin); in Rust it could be a simple struct assignment across modules. The goal isn't coverage—it's pain. Watch what happens when you swap a field name or drop a method. Structural systems will silently accept the imposter. Nominal systems will reject it even when the bytes match perfectly. That single experiment will teach you more than three blog posts. Wrong order? Run it twice: once with the actual types, once with a lookalike. The error message is your new cheat sheet.
Audit your codebase for 'shape-only' assumptions
Search your project for places where two types share the same fields but mean different things—a Price and a Quantity that both hold a single number, for instance. If your team uses structural typing by default, these are accidents waiting to explode. I once spent a Tuesday afternoon untangling a bug where a customer discount was applied to a subscription ID because both were strings. The catch is that "fixing" every shape collision with nominal wrappers adds ceremony you might not need. That said, each ambiguous string parameter is a hand grenade. Mark three files where a type Brand<T, B> or a newtype wrapper would save the next person forty minutes of head-scratching.
“We thought structural typing made refactoring easier—until someone swapped the coordinates array and the RGB array.”
— Lead engineer, mid-size React codebase, after a two-hour production incident
Decide on a convention for your team
Most teams skip this:. They let each developer choose whether to use interface or type, nominal-ish unique symbols or plain structural shapes. Then a pull request hits main where a LoginResponse accidentally satisfies a UserData contract because both have id, name, and token. Decide: will your critical domain types—money, identity, permissions—use branding or newtype patterns? And will you enforce that with a lint rule or just hope? The trade-off is real: pure structural typing lets you mock anything with a compatible object, which is great for tests. But that same flexibility erodes trust in production. Pick one rule, write it in your CONTRIBUTING.md, and hold the line for two sprints. Audit the results. Adjust. Ship with that confidence—not blind hope.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!