Skip to content

Why TypeScript and Structural Typing

intermediate11 min read

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.

Mental Model

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 UserId

This 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
Common Trap

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.

Execution Trace
Check:
Is ButtonProps assignable to Clickable?
Compiler checks: does ButtonProps have onClick: () => void? Yes. disabled?: boolean? Yes.
Match:
Structural match confirmed
Extra properties (label, variant, onMouseEnter, etc.) are ignored
Generic:
T is inferred as ButtonProps
The generic T extends Clickable captures the full type while constraining the shape
Result:
TrackedComponent accepts ButtonProps
Type-safe wrapping without the component knowing about tracking
Common Mistakes
  • 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.

Quiz
What happens when you assign an object with extra properties to a typed variable via an intermediate variable?
Quiz
Why did TypeScript choose structural typing over nominal typing?
Quiz
Given: type A = { x: number }; type B = { x: number; y: number }; — is B assignable to A?

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 PostId

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

Key Rules
  1. 1TypeScript uses structural typing — types are compatible based on shape, not name
  2. 2Structural typing is the static equivalent of JavaScript's runtime duck typing
  3. 3Excess property checking is a separate freshness check that only applies to direct object literal assignments
  4. 4Accept the minimum shape you need in function parameters — structural typing rewards interface segregation
  5. 5Use branded types (string & { __brand: unique symbol }) when you need nominal-like behavior
1/11