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.
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":
| Language | Same shape, different name = assignable? | Notes |
|---|---|---|
| Java | No | Must explicitly implement an interface or extend a class. |
| C# | No | record and class follow nominal rules. |
| Rust | No | Two structs with identical fields are unrelated. |
| Go | Mixed | Structs are nominal; interfaces are structural (implicit implements). |
| TypeScript | Yes | Pure structural — even an interface vs a type alias makes no difference. |
| OCaml/Reason | Yes | Structural 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
- Two domain values silently swappable —
UserIdandPostIdboth being plainstringlets you swap them. Fix: branded types or wrapper classes with#private. - 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.
ascast smuggling shapes through —value as Foobypasses structural checks entirely. Fix: validate at the boundary (Zod, schema decoder) before casting.- Function variance traps — assigning
(x: number) => voidto(x: number | string) => voidworks only withstrictFunctionTypes: false. KeepstrictFunctionTypeson. - Optional property vs missing property — without
exactOptionalPropertyTypes,{ a?: number }accepts{ a: undefined }and treats it like{}. With the flag, the distinction is preserved. - Empty type accepts everything —
{}(orObject) is the universal supertype. Any non-null value is assignable. UseRecord<string, never>or a strict shape instead. unknownis the safe top,anyis the unsafe one — both accept any value, butanypropagates structural-check disabling everywhere downstream. Always preferunknown.Array<T>is structurally an object with index signature — a custom type with the right keys can sneak in. Fix: useT[]and rely onArray.isArrayat runtime.- Class private modifiers vs ECMAScript privates —
private foo(TS modifier) is enforced only at compile time and still appears inObject.keys;#foois fully private at runtime. Use#for true encapsulation. type-fest'sOpaqueis being renamed toTagged—Opaque<T, B>still works; new code should useTagged<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.