Why TypeScript and Structural Typing
The Real Reason TypeScript Exists
JavaScript at scale is a game of telephone. A function written six months ago by someone who left the company accepts an object — but which fields? What types? What's optional? You check the implementation, trace the callers, read the tests, grep for usage. Twenty minutes later you have a guess.
TypeScript eliminates that guessing. But it doesn't do it the way Java or C# does. TypeScript uses structural typing — a fundamentally different approach that matches how JavaScript actually works, not how class-based languages wish it worked.
Think of structural typing as a bouncer checking a guest list by description, not by name. The bouncer doesn't care if your ID says "VIP Pass from EventSystem" — they check: "Do you have a name? Do you have a ticket number? Cool, you match the description. Come in." In nominal typing (Java, C#), the bouncer only lets you in if your ID was issued by the specific system they recognize. In structural typing, if you look like the right shape, you're the right type.
Structural vs Nominal Typing
In nominal type systems (Java, C#, Swift), types are defined by their name and explicit declaration. Two types with identical fields are still different types if they have different names:
// Java — nominal typing
class Point { int x; int y; }
class Coordinate { int x; int y; }
Point p = new Coordinate(1, 2); // ERROR: Coordinate is not Point
// Same fields, different names — incompatible
In structural type systems (TypeScript, Go interfaces), types are defined by their shape. If two types have the same structure, they're compatible:
// TypeScript — structural typing
type Point = { x: number; y: number };
type Coordinate = { x: number; y: number };
const p: Point = { x: 1, y: 2 };
const c: Coordinate = p; // OK — same shape
TypeScript doesn't care that Point and Coordinate are different names. It cares that both have x: number and y: number. Shape matches — assignment works.
Why TypeScript Chose Structural Typing
JavaScript is a dynamically-typed language where objects are just bags of properties. There are no class registries, no type declarations at runtime. When you write:
function greet(user) {
return `Hello, ${user.name}`;
}
This function works with any object that has a name property. It doesn't care if the object came from a User class, a Person class, or a plain object literal. This is duck typing — "if it walks like a duck and quacks like a duck, it's a duck."
TypeScript's structural typing is the static analysis version of JavaScript's duck typing. It formalizes what JavaScript already does at runtime.
interface HasName {
name: string;
}
function greet(user: HasName): string {
return `Hello, ${user.name}`;
}
// All of these work — they all have a name: string
greet({ name: "Alice" });
greet({ name: "Bob", age: 30 }); // Extra properties OK when passed from a variable
greet(new Employee("Carol")); // If Employee has name: string, it matches
class Dog {
name: string;
constructor(name: string) { this.name = name; }
}
greet(new Dog("Rex")); // A Dog has name: string — it matches HasName
Why not add nominal typing to TypeScript?
The TypeScript team considered nominal typing and explicitly chose structural typing. Anders Hejlsberg (TS creator, also created C#) explained that TypeScript must be a superset of JavaScript. JavaScript uses duck typing — any object with the right shape works. A nominal system would reject perfectly valid JavaScript patterns.
That said, you can simulate nominal types when you need them using branded types:
type UserId = string & { readonly __brand: unique symbol };
type OrderId = string & { readonly __brand: unique symbol };
function getUser(id: UserId) { /* ... */ }
const userId = "abc" as UserId;
const orderId = "abc" as OrderId;
getUser(userId); // OK
getUser(orderId); // ERROR — OrderId is not assignable to UserIdThis uses TypeScript's intersection types to add a phantom brand that makes structurally identical types incompatible. Libraries like io-ts and zod use this pattern extensively.
Excess Property Checking — The Exception That Confuses Everyone
There's one place where TypeScript appears to use nominal typing: object literal assignments:
interface Config {
host: string;
port: number;
}
// Direct object literal — excess property check kicks in
const config: Config = {
host: "localhost",
port: 3000,
timeout: 5000, // ERROR: Object literal may only specify known properties
};
// Via a variable — no excess property check
const raw = { host: "localhost", port: 3000, timeout: 5000 };
const config2: Config = raw; // OK — has host and port, extra properties ignored
Excess property checking is NOT structural typing being nominal. It's a separate lint-like check that only applies to direct object literal assignments. TypeScript assumes that if you're creating a fresh object literal and assigning it directly to a typed variable, you probably mistyped a property name. But when passing an existing variable, TypeScript respects structural typing — extra properties are fine.
This is the #1 source of "but I thought TypeScript was structural" confusion. Remember: excess property checking is a freshness check, not a type compatibility check.
Structural Typing in Practice — Production Scenario
Consider a React component library where different components accept overlapping props:
interface Clickable {
onClick: () => void;
disabled?: boolean;
}
interface Hoverable {
onMouseEnter: () => void;
onMouseLeave: () => void;
}
interface ButtonProps extends Clickable, Hoverable {
label: string;
variant: "primary" | "secondary";
}
// A tracking wrapper that only cares about click behavior
function withClickTracking<T extends Clickable>(
Component: React.ComponentType<T>,
trackingId: string
) {
return function TrackedComponent(props: T) {
const trackedClick = () => {
analytics.track(trackingId);
props.onClick();
};
return <Component {...props} onClick={trackedClick} />;
};
}
// Works with Button, Link, Card — anything with onClick + disabled?
const TrackedButton = withClickTracking(Button, "hero-cta");
The Clickable constraint doesn't care about the concrete type. Any component with onClick and optional disabled matches. This is structural typing enabling real composition.
-
Wrong: Thinking two types with the same shape but different names are incompatible Right: In TypeScript, shape determines compatibility, not name. Two identical shapes are always assignable to each other.
-
Wrong: Expecting excess property checks on variables (not just object literals) Right: Excess property checking only applies to fresh object literals. Variables with extra properties pass structural checks.
-
Wrong: Using 'as' casts to force incompatible types instead of fixing the structure Right: Fix the shape mismatch. If two types should be compatible, make their structures align.
-
Wrong: Creating unnecessarily specific parameter types instead of using structural constraints Right: Accept the minimum shape you need. Use 'extends' constraints on generics to accept any matching structure.
Branded Types for Type Safety
Show Answer
type UserId = string & { readonly __brand: unique symbol };
type PostId = string & { readonly __brand: unique symbol };
function getUser(id: UserId) {
return { id, name: "Alice" };
}
function getPost(id: PostId) {
return { id, title: "Hello World" };
}
const userId = "u_123" as UserId;
const postId = "p_456" as PostId;
getUser(userId); // OK
getPost(postId); // OK
// getUser(postId); // ERROR: PostId is not assignable to UserId
// getPost(userId); // ERROR: UserId is not assignable to PostIdThe unique symbol creates a distinct type per branded type declaration. TypeScript treats UserId and PostId as incompatible even though both are string intersections. This is the standard pattern for simulating nominal types in a structural system.
- 1TypeScript uses structural typing — types are compatible based on shape, not name
- 2Structural typing is the static equivalent of JavaScript's runtime duck typing
- 3Excess property checking is a separate freshness check that only applies to direct object literal assignments
- 4Accept the minimum shape you need in function parameters — structural typing rewards interface segregation
- 5Use branded types (
string & { __brand: unique symbol }) when you need nominal-like behavior