Dataslope logoDataslope

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.

Code Block
TypeScript 5.7

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 types

Even 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:

  1. No coordination required. Two teams can independently define interface User { id: string; name: string } and interface Account { id: string; name: string }, and they'll interoperate automatically. No need for a shared base type.
  2. 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.
  3. Gradual migration. When refactoring, you can introduce new types with the same shape and swap them in without rewriting call sites.
Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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 UserId

The __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.

Challenge
TypeScript 5.7
Making a structurally compatible logger

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:

  1. Change the parameter type of logItem from LogEntry to a more general shape.
  2. The test will pass various objects with extra properties — all should work as long as they have level and message.

Multiple choice check

QuestionSelect one

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 implements declarations.
  • Simulating nominal types: When structural typing is too loose (e.g., UserId vs. ProductId both 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.

On this page