cheat sheet

Structural Typing

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.

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.

bash
npm install -D typescript
npx tsc --version

Output:

text
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.

bash
npx tsc --noEmit scratch.ts

Output:

text
(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.

typescript
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.

typescript
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
bash
npx tsc --noEmit scratch.ts && node --experimental-strip-types scratch.ts

Output:

text
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.

typescript
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
bash
npx tsx scratch.ts

Output:

text
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.

typescript
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));
bash
npx tsx scratch.ts

Output:

text
Hi, Alice Dev

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

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

Output:

text
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.

typescript
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'
bash
npx tsc --noEmit scratch.ts

Output:

text
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):

typescript
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
bash
npx tsc --noEmit scratch.ts

Output:

text
(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:

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

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

Output:

text
(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.

typescript
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 }
bash
npx tsc --noEmit scratch.ts

Output:

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

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

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

Output:

text
(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".

typescript
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
bash
npx tsx scratch.ts

Output:

text
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.

typescript
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
bash
npx tsc --noEmit scratch.ts

Output:

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

typescript
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"));
bash
npx tsx scratch.ts

Output:

text
u-42

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

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

type UserId = Tagged<string, "UserId">;
type PostId = Tagged<string, "PostId">;
bash
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.

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

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

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

Output:

text
(no output — exit code 0)

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

typescript
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'
bash
npx tsc --noEmit scratch.ts

Output:

text
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.

typescript
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
bash
npx tsc --noEmit scratch.ts

Output:

text
(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:

typescript
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)
bash
npx tsx scratch.ts

Output:

text
[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.

typescript
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
bash
npx tsx scratch.ts

Output:

text
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.

typescript
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");
bash
npx tsx scratch.ts

Output:

text
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.

typescript
// 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
bash
npx tsx scratch.ts

Output:

text
[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.

typescript
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
bash
npx tsx scratch.ts

Output:

text
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.

typescript
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
bash
npx tsx scratch.ts

Output:

text
-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.

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

declare module "express" {
  interface Request {
    user?: { id: string; email: string };
  }
}
typescript
// 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();
}
bash
npx tsc --noEmit src/middleware.ts

Output:

text
(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.