Structural Typing
Why TypeScript cares about shape, not name
Most programmers learning TypeScript come from languages like Java, C#, or C++ where types are nominal: two types are compatible only if they have the same name. If class Dog and class Cat both have a name property, you still can't pass a Dog where a Cat is expected — the compiler checks the class name, not the structure.
TypeScript takes a different approach. It uses structural typing (also called duck typing for compilers): two types are compatible if they have the same shape. If it walks like a duck and quacks like a duck, the compiler treats it as a duck — even if it's technically a goose.
This makes TypeScript remarkably flexible and composable. You can reuse types across unrelated modules without needing a shared base class or interface. But it also has surprising edge cases, especially around object literals. Let's explore how structural typing works and when it bites.
Shape-based compatibility
In TypeScript, a type is compatible with another if it has at least the required properties, with the correct types. The name doesn't matter.
Point and Vector are structurally identical — both have x: number and y: number. So the compiler treats them as interchangeable. This is structural compatibility: if the shapes match, the types match.
Contrast this with a nominal system like Java:
// Java (nominal typing)
class Point { int x, y; }
class Vector { int x, y; }
void printPoint(Point p) { /*...*/ }
Vector v = new Vector();
printPoint(v); // ❌ Compile error: incompatible typesEven though Point and Vector have identical fields, Java rejects the call because the names differ. TypeScript doesn't care about names — only structure.
Why structural typing matters
This design choice unlocks powerful patterns:
- No coordination required. Two teams can independently define
interface User { id: string; name: string }andinterface Account { id: string; name: string }, and they'll interoperate automatically. No need for a shared base type. - Easy testing and mocking. You can pass any object with the right shape to a function, even if it's a plain object literal or a test stub. No need to extend a base class or implement an interface explicitly.
- Gradual migration. When refactoring, you can introduce new types with the same shape and swap them in without rewriting call sites.
This is why TypeScript is often described as having duck typing for compilers: "If it looks like a duck and quacks like a duck, it's a duck."
Contrast with Go's interfaces
Go also uses structural typing for interfaces, but it's stricter: a type must implement all methods of an interface to satisfy it. TypeScript is more permissive — extra properties are fine, and even functions can be structurally compatible if their parameter and return types align.
Excess property checking: the surprise
Structural typing says "extra properties are OK," but TypeScript has a special rule for object literals that seems to contradict this.
Wait — why does myConfig work but the inline literal would fail? This is excess property checking, a special-case carveout that TypeScript applies only to fresh object literals. The goal is to catch typos and accidental properties:
// Typo: "prot" instead of "port"
connect({ host: "localhost", prot: 3000 }); // ❌ Error caught!Without excess property checking, this typo would slip through: prot is an extra property, and structural typing normally allows extra properties. The compiler would accept it, config.port would be undefined at runtime, and you'd get a confusing error much later.
So TypeScript adds a freshness check: when you pass an object literal directly to a function or assign it to a typed variable, the compiler rejects any properties not declared in the target type. This is a deliberate break from pure structural typing to improve ergonomics.
Fresh vs. non-fresh
An object literal is "fresh" when it's created inline as an argument or assignment. Once you store it in a variable (even without a type annotation), it loses freshness and reverts to structural compatibility. This is why myConfig above works — by the time it reaches connect, it's no longer a fresh literal.
Bypassing excess property checking
If you genuinely need extra properties, you have a few options:
Each approach has trade-offs. Type assertions (as Config) are a last resort — they silence the compiler but don't make your code safer. Index signatures are better when you genuinely expect arbitrary properties (like a config object with plugin options). Storing in a variable is the cleanest if you're just working around the freshness check.
Structural typing in practice
Let's see a realistic example. Suppose you're building a logging system and want to accept anything with a message property:
None of these objects explicitly "implement" Loggable. They just happen to have a message: string property, so they're compatible. This is structural typing in action: the log function works with any object that has the right shape, without needing a shared base class or interface declaration.
Simulating nominal typing
Sometimes structural typing is too loose. Suppose you're modeling user IDs and product IDs as strings:
type UserId = string;
type ProductId = string;
function deleteUser(id: UserId) { /*...*/ }
deleteUser(productId); // ❌ Logic error, but type-checks!Both are just string under the hood, so TypeScript treats them as interchangeable. This is dangerous — you could accidentally pass a product ID where a user ID is expected.
The solution is branded types (also called opaque types or nominal types). You add a fake property that exists only at compile time to make the types structurally distinct:
type UserId = string & { readonly __brand: "UserId" };
type ProductId = string & { readonly __brand: "ProductId" };
function deleteUser(id: UserId) { /*...*/ }
const userId = "user-123" as UserId;
const productId = "prod-456" as ProductId;
deleteUser(userId); // ✅ OK
deleteUser(productId); // ❌ Error: ProductId is not assignable to UserIdThe __brand property doesn't exist at runtime (JavaScript ignores it), but at compile time it makes UserId and ProductId structurally different. This is a manual workaround to get nominal-like behavior in a structural system.
We'll explore branded types in more detail in the Branded Types chapter. For now, just know that structural typing is the default, but you can opt into nominal checks when you need them.
Best of both worlds
TypeScript's structural typing gives you flexibility by default, but you can layer on nominal checks (via branding) when you need to prevent accidental mixing of logically distinct types. This is more flexible than a purely nominal system, where you're locked in from the start.
Challenge: Structural compatibility
Let's test your understanding. You'll fix a function so it works with any object that has the required shape.
The logItem function currently expects a specific LogEntry type, but we want it to work with any object that has level and message properties.
Your task:
- Change the parameter type of
logItemfromLogEntryto a more general shape. - The test will pass various objects with extra properties — all should work as long as they have
levelandmessage.
Multiple choice check
Given these two interfaces:
interface Dog {
name: string;
breed: string;
}
interface Pet {
name: string;
}
Which statement is true in TypeScript's structural type system?
Both Dog and Pet are assignable to each other because they share the name property.
Dog is assignable to Pet because it has all required properties (and more).
Pet is assignable to Dog because Dog is more specific.
Neither is assignable to the other because they have different names.
Recap
TypeScript's structural type system checks compatibility by shape, not name. If two types have the same structure (the same properties with the same types), they're interchangeable — even if they were defined in completely separate modules with no shared ancestry.
Key takeaways:
- Structural compatibility: Two types are compatible if one has all the properties of the other (with the right types). Extra properties are allowed.
- Excess property checking: A special rule for fresh object literals that rejects undeclared properties to catch typos. Once a literal is stored in a variable, it reverts to normal structural rules.
- Duck typing for compilers: If it has the right shape, it fits — no need for explicit
implementsdeclarations. - Simulating nominal types: When structural typing is too loose (e.g.,
UserIdvs.ProductIdboth being strings), you can use branded types to make logically distinct types structurally distinct.
Structural typing makes TypeScript composable and flexible. You can define types in isolation and have them work together automatically. But it also requires care: always be aware of what shape you're actually checking, and use branding or more precise types when you need to prevent accidental misuse.
Next: Discriminated Unions, where we combine structural typing with a "tag" field to model state machines, domain events, and other variants — the most powerful pattern in modern TypeScript.