Skip to main content

Structural typing (duck typing) in TypeScript

Structural typing in TypeScript checks object shape (properties and their types), not the class name or declaration. If an object has everything a type requires, it passes.

Theory

TL;DR

  • Shape match beats name match. An object with x: number satisfies interface HasX { x: number } without any implements
  • Extra properties on an object passed via variable? Fine. Extra properties on a fresh object literal? Error
  • TypeScript checks structure at compile time only. Zero runtime cost
  • Java/C# check names; TypeScript checks structure. That is the whole difference
  • When two identical shapes represent different things (currencies, IDs), use branded types to enforce separation

Quick Example

typescript
interface HasX { x: number; } const point = { x: 10, extra: "ignored" }; // no "implements HasX" function useX(item: HasX): number { return item.x * 2; } console.log(useX(point)); // 20 ✅ shape matches

point has an extra property, but TypeScript only checks that x: number exists. It does, so the call compiles. The extra property is ignored.

Key Difference: Shape vs. Name

In Java or C#, two classes with identical methods are still distinct types unless one declares implements or extends the other. TypeScript does not look at names. If the required properties exist with matching types, the types are compatible.

typescript
interface Cat { meow(): void; } interface CatLike { meow(): void; } const cat: Cat = { meow() {} }; const catLike: CatLike = cat; // ✅ same structure, compatible

In a nominal system this would fail. Cat and CatLike are separate names, so they are separate types regardless of content.

When to Use

  • Third-party integrations: accept any object with the required shape, no wrapper classes needed
  • React props: any component that passes the right shape works, no explicit class inheritance
  • Express middleware: custom req extensions are accepted because the shape grows structurally
  • Avoid when shapes must stay semantically distinct. Currencies, user IDs, entity keys: use branded types instead

Structural vs. Nominal

AspectStructural (TypeScript)Nominal (Java/C#)
Compatibility basisShape matchName + explicit declaration
Extra propertiesAllowed via variableNot applicable
implements keywordOptionalRequired
Error styleShape drift can go unnoticedExplicit declaration mismatch
Best forEvolving APIs, librariesStrict contracts, financial systems

Excess Property Checking

Structural typing has one special rule. Object literals assigned directly to a typed variable trigger excess property checking. Pass the same object through a variable first, and the check disappears.

typescript
interface Config { host: string; port: number; } // ❌ direct literal - excess property error const cfg: Config = { host: "localhost", port: 3000, debug: true }; // Error: 'debug' does not exist in type 'Config' // ✅ through a variable - no excess check const obj = { host: "localhost", port: 3000, debug: true }; const cfg2: Config = obj; // Works

This asymmetry trips people up. Both cases are structural, but the first gets extra attention because TypeScript sees a fresh object it knows everything about.

How the Compiler Handles This

TypeScript's compiler (tsc) runs structural checks during type assignment: every required member of the target type must appear in the source with a compatible type. No runtime cost. Types are erased before the JavaScript runs, so Node.js and the browser never see them.

Class instances follow the same rule. Public members only:

typescript
class Animal { name: string; constructor(n: string) { this.name = n; } } class Person { name: string; constructor(n: string) { this.name = n; } } let a: Animal = new Person("Alice"); // ✅ same public shape

Add private members and the story changes. Each class owns its private fields, and TypeScript treats them as distinct per class, so two classes with private fields are no longer structurally compatible even if they look identical.

Common Mistakes

Mistake 1: Treating shape drift as harmless

typescript
interface Point { x: number; y: number; } const p = { x: 1, y: 2, z: 3 }; const p2: Point = p; // ✅ compiles function move(pt: Point) { // pt.z is accessible at runtime but TypeScript has no type info for it console.log((pt as any).z); // type safety is lost here }

The assignment compiles, but you lose access to .z through the type. Fix with an index signature or extend the interface explicitly.

Mistake 2: Assigning a narrower type to a wider one

typescript
type A = { a: number }; type B = { a: number; b: string }; const narrow: A = { a: 1 }; const wide: B = narrow; // ❌ Missing property 'b'

A does not have b, so it cannot satisfy B. The reverse always works: a B can go anywhere an A is expected.

Mistake 3: Private members blocking structural compatibility

typescript
class Dog { private breed: string; constructor(b: string) { this.breed = b; } bark() {} } class Cat { private breed: string; constructor(b: string) { this.breed = b; } bark() {} } const d: Dog = new Cat("Persian"); // ❌ private field mismatch

The private breed fields belong to different classes. TypeScript blocks this even though the classes look identical.

Mistake 4: Relying on structural typing for semantic safety

typescript
type USD = number; type EUR = number; function convertToEUR(amount: USD): EUR { return amount * 0.85; } const euros: EUR = 100; convertToEUR(euros); // ✅ no error, but logically wrong

I've seen this exact pattern in financial code where two number aliases silently accepted each other for months before someone noticed. Both types are just number, so TypeScript cannot tell them apart. Branded types fix this.

Real-World Usage

  • React: props interfaces accept any object with the correct shape, no explicit class needed
  • Express: middleware receives (req: Request, res: Response); custom req properties work if you extend the type structurally
  • Redux: action creators return { type: string; payload?: any }; any matching object can be dispatched
  • Node.js: stream-like objects plug into APIs without formal implements

Follow-Up Questions

Q: What is excess property checking and when does it trigger?
A: It triggers when you assign an object literal directly to a typed variable or pass it as a direct argument. Passing through an intermediate variable skips the check because TypeScript treats the object as pre-existing.

Q: Can two classes be structurally incompatible even with the same methods?
A: Yes. Private or protected members make classes incompatible. Each class owns its private members, and TypeScript treats them as distinct even if the field names match.

Q: How do you enforce nominal typing in TypeScript?
A: Brand the type with a unique symbol: type USD = number & { readonly __brand: unique symbol }. This adds a phantom property that exists only at the type level and blocks accidental assignments.

Q: Why does assigning a fresh object literal to a supertype fail?
A: TypeScript applies excess property checking to fresh literals. If the target type does not declare the extra property, TypeScript errors. Assign to a variable first and the check is skipped.

Q: Does structural typing have any runtime cost?
A: None. All type information is erased before JavaScript runs. Structural checking is a compile-time operation only.

Q: (Senior) A function type is (e: string) => void. Can you assign (e: string, n: number) => void to it?
A: No. Function parameters check contravariantly. The extra parameter n makes the signatures incompatible because the caller does not provide it.

Examples

Basic: Object Shape Matching

typescript
interface User { id: number; name: string; } // extra property 'role' - no implements, no class const admin = { id: 1, name: "Alice", role: "admin" }; function greet(user: User): string { return `Hello, ${user.name}`; } console.log(greet(admin)); // "Hello, Alice" ✅

admin has an extra role property, but User only requires id and name. TypeScript sees both, so the call is valid. role is simply outside the type's view.

Intermediate: React Component Props

typescript
interface ButtonProps { children: React.ReactNode; onClick?: () => void; className?: string; } const Button = ({ children, onClick, className }: ButtonProps) => ( <button onClick={onClick} className={className}> {children} </button> ); const handleClick = () => console.log("clicked"); const usage = ( <Button onClick={handleClick} className="btn-primary"> Submit </Button> ); // ✅ shape matches ButtonProps

ButtonProps does not care where handleClick comes from or how it was declared. It only checks the function signature shape. This is structural typing doing its job in real production code.

Advanced: Branded Types for Nominal Behavior

When two types share the same structure but must stay separate, branded types add a type-level marker:

typescript
// plain aliases - structurally identical, TypeScript cannot tell them apart type USD = number; type EUR = number; // branded aliases - structurally different at the type level type BrandedUSD = number & { readonly __brand: unique symbol }; type BrandedEUR = number & { readonly __brand: unique symbol }; // constructor functions are the only way to create values function makeUSD(n: number): BrandedUSD { return n as BrandedUSD; } function makeEUR(n: number): BrandedEUR { return n as BrandedEUR; } function convertToEUR(amount: BrandedUSD): BrandedEUR { return (amount * 0.85) as BrandedEUR; } const dollars = makeUSD(100); convertToEUR(dollars); // ✅ convertToEUR(makeEUR(100)); // ❌ BrandedEUR is not assignable to BrandedUSD

The __brand property never exists at runtime. It is a phantom field TypeScript uses only during type checking. This is the standard way to get nominal-like behavior from a structural type system.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?