skip to content

Structural Typing — Duck Typing at the Type Level

Understand TypeScript's structural type system — assignability is based on shape, not name; excess property checks are the one exception; nominal typing requires branded types or class privates.

16 min read 74 snippets deep dive

Structural Typing — Duck Typing at the Type Level#

What it is#

TypeScript is structurally typed — two types are compatible when they have the same shape, regardless of where they were declared or what they’re named. If a value has every property the type requires (of compatible types), it’s assignable. “If it walks like a duck and quacks like a duck, it’s a duck” — applied to the type-checker rather than runtime. This is the opposite of nominal typing (Java, C#, Rust), where two types with identical members are still incompatible unless one explicitly extends the other. Structural typing is what makes TypeScript feel ergonomic — you don’t have to wrap library values in your own wrapper classes — but it can bite when “two strings” or “two records with the same fields” should be treated as different domains (a UserId is not a PostId). The escape hatches are branded types (a virtual property nobody actually sets) and class with private members (private fields participate in identity). This page covers the rules, the one exception (excess-property checks), and the patterns that recover nominal-ish behaviour when you need it.

Install#

Structural typing is intrinsic to TypeScript — no install required. The examples in this article assume TypeScript 5.4+ with strict: true in tsconfig.json.

npm install -D typescript
npx tsc --version

Output:

Version 5.4.5

To follow along, paste any block into the TypeScript Playground (https://www.typescriptlang.org/play) or write it to scratch.ts and run npx tsc --noEmit scratch.ts.

npx tsc --noEmit scratch.ts

Output:

(no output — exit code 0)

Syntax#

There’s no special syntax to opt into structural typing — it’s the default. The “rule” lives in the assignability algorithm: T is assignable to U if every member required by U exists in T with a compatible type.

type T = { /* shape */ };
type U = { /* shape */ };

function f(u: U) { /* ... */ }
const t: T = { /* matching shape */ };
f(t); // OK iff T's shape covers U's

Output: (none — exits 0 on success)

The core rule — shape, not name#

Two types with the same members are interchangeable. The type-checker never looks at the alias name — only the resolved structure.

type Point = { x: number; y: number };
type Coord = { x: number; y: number };

function distance(p: Point): number {
  return Math.hypot(p.x, p.y);
}

const c: Coord = { x: 3, y: 4 };
console.log(distance(c)); // OK — Coord and Point have the same shape
npx tsc --noEmit scratch.ts && node --experimental-strip-types scratch.ts

Output:

5

Compare to Java, where Point and Coord with identical fields are distinct types and the equivalent code wouldn’t compile. In TypeScript, naming a type is purely a documentation convenience.

interface Point { x: number; y: number }
interface Coord { x: number; y: number }

function distance(p: Point) { return Math.hypot(p.x, p.y); }

const c: Coord = { x: 3, y: 4 };
console.log(distance(c)); // still OK — interface vs type alias makes no difference
npx tsx scratch.ts

Output:

5

Width subtyping — more is OK, less is not#

A value with more properties than the target type requires is assignable. A value with fewer is not. This is sometimes called “width subtyping” because the wider record (more columns) fits into a narrower slot.

type Named = { name: string };

const alice = { name: "Alice Dev", email: "alice@example.com", age: 30 };

const n: Named = alice; // OK — alice has name and other properties

function greet(p: Named): string {
  return `Hi, ${p.name}`;
}

console.log(greet(alice));
npx tsx scratch.ts

Output:

Hi, Alice Dev

The function only reads name, so passing a richer object is safe. The reverse — passing a leaner object — fails:

const minimal = { age: 30 };
const n: Named = minimal; // ERROR: Property 'name' is missing
npx tsc --noEmit scratch.ts

Output:

scratch.ts:2:7 - error TS2741: Property 'name' is missing in type '{ age: number; }' but required in type 'Named'.

2 const n: Named = minimal;
        ~
Found 1 error in scratch.ts:2

Excess property checks — the one exception#

The “more is OK” rule has a deliberate exception: when you pass an object literal directly to a parameter or assign it directly to a typed variable, TypeScript runs an excess property check and rejects extra fields. This catches typos like widht instead of width. Aliased variables don’t get this check — only literals at the assignment site.

type Button = { label: string; disabled?: boolean };

// Direct literal — excess property check fires
const b1: Button = { label: "OK", disabled: false, color: "blue" };
// ERROR: 'color' does not exist in type 'Button'
npx tsc --noEmit scratch.ts

Output:

scratch.ts:4:46 - error TS2353: Object literal may only specify known properties, and 'color' does not exist in type 'Button'.

4 const b1: Button = { label: "OK", disabled: false, color: "blue" };
                                                   ~~~~~~~~~~~~~
Found 1 error in scratch.ts:4

Stash the literal in a variable first, and the check no longer fires (TypeScript trusts you when you’ve stored the value separately):

type Button = { label: string; disabled?: boolean };

const raw = { label: "OK", disabled: false, color: "blue" };
const b: Button = raw; // OK — width subtyping, no excess property check
npx tsc --noEmit scratch.ts

Output:

(no output — exit code 0)

Excess property checks are a usability nudge, not a soundness guarantee. To bypass intentionally, add an index signature or use a spread:

type Button = { label: string; [key: string]: unknown };

const b: Button = { label: "OK", color: "blue" }; // OK now — index signature covers 'color'
npx tsx scratch.ts

Output:

(no output — exit code 0)

Function parameter compatibility#

Functions are compared structurally too: a function is assignable to another if its parameters and return type are compatible. The rule is contravariant for parameters under strictFunctionTypes — a callback that accepts a wider parameter type is assignable to a slot expecting a narrower one.

type Handler = (e: { type: string }) => void;

// Wider parameter type — accepts ANY object, including { type: string }
const generic: (e: object) => void = (e) => console.log(e);
const h: Handler = generic; // OK — generic can be safely called as Handler

// Narrower parameter type — requires a 'type' AND 'name' field
const specific: (e: { type: string; name: string }) => void = (e) => console.log(e.name);
const h2: Handler = specific; // ERROR — specific would crash when called with just { type }
npx tsc --noEmit scratch.ts

Output:

scratch.ts:11:7 - error TS2322: Type '(e: { type: string; name: string; }) => void' is not assignable to type 'Handler'.
  Types of parameters 'e' and 'e' are incompatible.
    Property 'name' is missing in type '{ type: string; }' but required in type '{ type: string; name: string; }'.

11 const h2: Handler = specific;
        ~~
Found 1 error in scratch.ts:11

Return types are covariant — a function that returns more specific data is assignable to a slot expecting a more general return:

type Maker = () => { name: string };

const richMaker = () => ({ name: "Alice Dev", age: 30 });
const m: Maker = richMaker; // OK — richMaker returns a superset of { name: string }
npx tsc --noEmit scratch.ts

Output:

(no output — exit code 0)

When structural typing bites#

The same flexibility that makes TypeScript ergonomic also breaks domain distinctions. Two strings are interchangeable even when one represents a user ID and another a post ID; two records with { amount: number, currency: string } are interchangeable even when one is “money in” and the other “money out”.

function transfer(fromUser: string, toUser: string, amount: number) {
  console.log(`${fromUser} -> ${toUser}: ${amount}`);
}

const userId = "u-123";
const postId = "p-456";

transfer(postId, userId, 100); // BUG — swapped, but type-checker is happy
npx tsx scratch.ts

Output:

p-456 -> u-123: 100

The arguments are in the wrong order but both are string, so TypeScript can’t catch it. The fix is to give the type-checker something to distinguish them — that’s what branded types do.

Branded (nominal) types — the escape hatch#

A branded type is a regular primitive intersected with a “virtual” property that exists only in the type system. The brand has no runtime cost — it’s erased on compilation — but it prevents accidental mixing because the bare primitive doesn’t carry the brand and thus isn’t assignable.

type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };

// Factory functions are the only way to create a branded value
function userId(s: string): UserId {
  return s as UserId;
}
function postId(s: string): PostId {
  return s as PostId;
}

function transfer(fromUser: UserId, toUser: UserId, amount: number) {
  console.log(`${fromUser} -> ${toUser}: ${amount}`);
}

const u1 = userId("u-123");
const u2 = userId("u-456");
const p1 = postId("p-789");

transfer(u1, u2, 100);    // OK
transfer(u1, p1, 100);    // ERROR — PostId is not assignable to UserId
transfer("u-123", u2, 100); // ERROR — plain string is not assignable to UserId
npx tsc --noEmit scratch.ts

Output:

scratch.ts:18:14 - error TS2345: Argument of type 'PostId' is not assignable to parameter of type 'UserId'.
  Type '"PostId"' is not assignable to type '"UserId"'.

18 transfer(u1, p1, 100);
              ~~

scratch.ts:19:10 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type 'UserId'.

19 transfer("u-123", u2, 100);
          ~~~~~~~
Found 2 errors in scratch.ts

The brand is purely a type-level fiction — at runtime u1 and u2 are ordinary strings. But the type-checker now treats UserId and PostId as distinct, exactly as a nominal language would.

The as UserId cast inside the factory is the only “trust me” line. Wrap it with runtime validation (regex, Zod, decoder) to make the boundary safe:

type UserId = string & { readonly __brand: "UserId" };

function userId(s: string): UserId {
  if (!/^u-\d+$/.test(s)) {
    throw new TypeError(`invalid UserId: ${s}`);
  }
  return s as UserId;
}

console.log(userId("u-42"));
npx tsx scratch.ts

Output:

u-42

For a battle-tested branded helper, type-fest ships Tagged<T, B>:

import type { Tagged } from "type-fest";

type UserId = Tagged<string, "UserId">;
type PostId = Tagged<string, "PostId">;
npm install --save-dev type-fest

Output: (none — exits 0 on success)

Classes — private members participate in identity#

Classes look like one place TypeScript might be nominal, but two classes with identical public shapes are still structurally compatible. The exception is #private fields (ECMAScript privates) and TypeScript’s private modifier — those participate in identity. A class with a private field is only assignable to itself.

class Cat {
  constructor(public name: string) {}
}

class Dog {
  constructor(public name: string) {}
}

const k: Cat = new Dog("Rex"); // OK — same public shape, structural match
npx tsc --noEmit scratch.ts

Output:

(no output — exit code 0)

Add a #private field, and the classes are no longer compatible:

class Cat {
  #species = "felis catus";
  constructor(public name: string) {}
}

class Dog {
  #species = "canis lupus";
  constructor(public name: string) {}
}

const k: Cat = new Dog("Rex"); // ERROR — Property '#species' in type 'Dog' refers to a different member than the same-named property in 'Cat'
npx tsc --noEmit scratch.ts

Output:

scratch.ts:11:7 - error TS2741: Property '#species' in type 'Cat' is not the same as in type 'Dog'.
  Property '#species' is missing in type 'Dog' but required in type 'Cat'.

11 const k: Cat = new Dog("Rex");
        ~
Found 1 error in scratch.ts

This is the easiest “nominal” pattern when you already need a class: add a single #brand field and you’ve made the class identity-checked.

class UserId {
  #brand!: "UserId";
  constructor(public readonly value: string) {}
}

class PostId {
  #brand!: "PostId";
  constructor(public readonly value: string) {}
}

function getUser(id: UserId) { /* ... */ }

getUser(new UserId("u-1")); // OK
getUser(new PostId("p-2") as unknown as UserId); // requires explicit double-cast
npx tsc --noEmit scratch.ts

Output:

(no output — exit code 0)

Compared with nominal languages#

A quick contrast across three languages, each defining “two record types with the same shape”:

LanguageSame shape, different name = assignable?Notes
JavaNoMust explicitly implement an interface or extend a class.
C#Norecord and class follow nominal rules.
RustNoTwo structs with identical fields are unrelated.
GoMixedStructs are nominal; interfaces are structural (implicit implements).
TypeScriptYesPure structural — even an interface vs a type alias makes no difference.
OCaml/ReasonYesStructural for objects, nominal for variants.

For interop with other languages — say, generating TypeScript types from a Rust schema — the takeaway is: TS will happily merge two distinct Rust types into one if their fields match. You may need branded types to preserve the source’s nominal distinction.

Function and class compatibility together#

A subtle case — a function type and a class instance type can be assignable if the class instance is callable. Conversely, instances of “compatible” classes pass without complaint:

class Logger {
  log(msg: string) { console.log(msg); }
}

class Tracer {
  log(msg: string) { console.log(`[trace] ${msg}`); }
  level: number = 0;
}

function record(l: Logger) {
  l.log("hello");
}

record(new Tracer()); // OK — Tracer is a superset of Logger (extra 'level' is fine)
npx tsx scratch.ts

Output:

[trace] hello

This is why TypeScript libraries often type their parameters as interface Foo { ... } rather than as a concrete class — any object that happens to satisfy Foo works, including duck-typed mocks in tests.

instanceof and runtime identity#

Structural typing applies to the type system. At runtime, instanceof still uses the prototype chain, which is nominal — two structurally-compatible objects from different classes give different instanceof answers.

class A {
  greet() { return "hi from A"; }
}

class B {
  greet() { return "hi from B"; }
}

function isA(x: unknown): x is A {
  return x instanceof A;
}

const a = new A();
const b = new B();

console.log(isA(a)); // true
console.log(isA(b)); // false — even though B is structurally compatible with A
npx tsx scratch.ts

Output:

true
false

Use instanceof (a type guard) when you need runtime identity. Use structural types when you want maximum flexibility.

Common pitfalls#

  1. Two domain values silently swappableUserId and PostId both being plain string lets you swap them. Fix: branded types or wrapper classes with #private.
  2. Excess property check confusion — fields rejected on literals but accepted via a variable. Fix: assign to an intermediate variable if you need the extra props, or add them to the type definition.
  3. as cast smuggling shapes throughvalue as Foo bypasses structural checks entirely. Fix: validate at the boundary (Zod, schema decoder) before casting.
  4. Function variance traps — assigning (x: number) => void to (x: number | string) => void works only with strictFunctionTypes: false. Keep strictFunctionTypes on.
  5. Optional property vs missing property — without exactOptionalPropertyTypes, { a?: number } accepts { a: undefined } and treats it like {}. With the flag, the distinction is preserved.
  6. Empty type accepts everything{} (or Object) is the universal supertype. Any non-null value is assignable. Use Record<string, never> or a strict shape instead.
  7. unknown is the safe top, any is the unsafe one — both accept any value, but any propagates structural-check disabling everywhere downstream. Always prefer unknown.
  8. Array<T> is structurally an object with index signature — a custom type with the right keys can sneak in. Fix: use T[] and rely on Array.isArray at runtime.
  9. Class private modifiers vs ECMAScript privatesprivate foo (TS modifier) is enforced only at compile time and still appears in Object.keys; #foo is fully private at runtime. Use # for true encapsulation.
  10. type-fest’s Opaque is being renamed to TaggedOpaque<T, B> still works; new code should use Tagged<T, B> to match upstream.

Real-world recipes#

Type-safe IDs across an API#

Stop bug categories where a UserId is passed where a PostId is expected by branding both. The factory functions can wrap a Zod parse for runtime validation.

import { z } from "zod";

type UserId = string & { readonly __brand: "UserId" };
type PostId = string & { readonly __brand: "PostId" };

const UserIdSchema = z.string().regex(/^u-\d+$/).brand<"UserId">();
const PostIdSchema = z.string().regex(/^p-\d+$/).brand<"PostId">();

const userId = (s: string): UserId => UserIdSchema.parse(s);
const postId = (s: string): PostId => PostIdSchema.parse(s);

function getUserPosts(uid: UserId, pid: PostId): void {
  console.log(`user=${uid} post=${pid}`);
}

const u = userId("u-1");
const p = postId("p-2");

getUserPosts(u, p);       // OK
// getUserPosts(p, u);    // type error AND would throw at runtime if attempted

console.log("typecheck and brand validation passed");
npx tsx scratch.ts

Output:

user=u-1 post=p-2
typecheck and brand validation passed

Accept any object with the right shape vs a specific instance#

The classic split — “I just need anything with log(msg)” is structural; “I need an actual MyLogger because I rely on its identity” is nominal.

// Structural — accepts duck-typed mocks in tests
interface Logger {
  log(msg: string): void;
}

function doWork(logger: Logger) {
  logger.log("starting work");
}

const realLogger = { log: (m: string) => console.log(`[real] ${m}`) };
doWork(realLogger); // OK — no class needed

// Nominal — accepts only MyLogger instances
class MyLogger {
  #brand!: "MyLogger";
  log(msg: string) {
    console.log(`[my] ${msg}`);
  }
}

function doSecureWork(logger: MyLogger) {
  logger.log("starting secure work");
}

doSecureWork(new MyLogger());
// doSecureWork(realLogger); // ERROR — structural mock is rejected
npx tsx scratch.ts

Output:

[real] starting work
[my] starting secure work

Runtime tagged unit conversion#

Pair branded types with a unit-of-measure pattern — distinguish meters from feet at compile time, with no runtime overhead.

type Meters = number & { readonly __unit: "m" };
type Feet = number & { readonly __unit: "ft" };

const m = (n: number): Meters => n as Meters;
const ft = (n: number): Feet => n as Feet;

function metersToFeet(d: Meters): Feet {
  return ft(d * 3.28084);
}

const distance = m(100);
const inFeet = metersToFeet(distance);
console.log(`${distance} m = ${inFeet} ft`);

// metersToFeet(ft(100)); // ERROR — Feet is not assignable to Meters
// metersToFeet(100);     // ERROR — plain number is not assignable
npx tsx scratch.ts

Output:

100 m = 328.084 ft

Detecting “anything object-like” safely#

When you genuinely want “any object with a .length property”, unknown plus a structural type guard is safer than any. The guard narrows at runtime; the type narrows at compile time.

interface HasLength {
  length: number;
}

function isHasLength(x: unknown): x is HasLength {
  return typeof x === "object" && x !== null && "length" in x && typeof (x as Record<string, unknown>).length === "number";
}

function measure(x: unknown): number {
  if (isHasLength(x)) {
    return x.length; // narrowed safely
  }
  return -1;
}

console.log(measure("hello"));        // -1 (strings aren't objects)
console.log(measure([1, 2, 3]));      // 3
console.log(measure({ length: 7 }));  // 7
console.log(measure(null));           // -1
npx tsx scratch.ts

Output:

-1
3
7
-1

Augmenting a library type without monkey-patching at runtime#

Structural typing lets you describe a “shape” without owning the implementation. Combined with declaration merging, you can extend a library’s interface for your own consumers’ files without modifying the library.

// types/express-augmentation.d.ts
import "express";

declare module "express" {
  interface Request {
    user?: { id: string; email: string };
  }
}
// src/middleware.ts
import type { Request, Response, NextFunction } from "express";

export function setUser(req: Request, res: Response, next: NextFunction) {
  req.user = { id: "u-1", email: "alice@example.com" }; // typed thanks to augmentation
  next();
}
npx tsc --noEmit src/middleware.ts

Output:

(no output — exit code 0)

The structural type of Request is now wider for every consumer that includes the augmentation file — no runtime change, no fork of Express, no class wrapping.