The Type System: Structural Typing and Narrowing

Every typed language has a type system, but they differ radically in one question: when are two types considered compatible? TypeScript answers differently from Java, C#, or Rust, and that single difference is what makes it usable on a JavaScript codebase. Master structural typing and narrowing here, and the rest of the type system becomes intuitive.

Structural typing: compatibility by shape

In nominal systems (Java, C#), a value's type is whatever its declaration says — two classes are incompatible unless one inherits the other, even with identical members. TypeScript instead compares shapes: a value is assignable to a type if it has the members that type requires. The type name is irrelevant; only the structure matters.

A Point type requiring x and y is satisfied by objects with more properties, because their shape contains the required members
Figure 1 — Structural compatibility. The variable needs "at least these members". Extra members do not hurt; missing ones do. Nominal languages would reject the same assignment because the class names differ.

Duck typing, made into a contract

JavaScript developers already practice structural thinking — "if it quacks like a duck" is structural typing at runtime. TypeScript promotes that intuition from convention to a compile-time contract.

Purpose of the example: show that ordinary JavaScript objects satisfy TypeScript types without any declaration changes.

// The contract: anything that can position itself on a canvas.
interface Drawable {
  x: number;
  y: number;
  draw(): void;
}

// Plain JS-style object — no `implements` clause, no class at all.
const circle = {
  x: 10, y: 20, radius: 5,      // extra members are fine
  draw() { console.log(`circle at ${this.x},${this.y}`); },
};

const dot = {
  x: 1, y: 2,
  // no draw() — missing a required member
};

function render(d: Drawable): void { d.draw(); }

render(circle);   // OK: shape has x, y, draw
render(dot);      // ✗ TS2739: Property 'draw' is missing

What you should see: circle is accepted with no implements Drawable anywhere — the checker only compared members. dot fails with error TS2739, listing exactly which member is missing.

What we learn: adopt types incrementally on existing JavaScript — the objects you already have usually already conform. Also note the flip side: render({ x: 0, y: 0, draw() {} }) inline is fine, but assigning a freshly written object literal with excess properties to a typed variable triggers excess property checking — the one place the checker suspects typos.

Why this matters for JavaScript codebases

Nominal typing forces you to wrap, adapt, and cast when integrating libraries — their User is not your User. Structural typing means JSON from fetch, a third-party result object, and your domain type can all line up by shape. It is the reason TypeScript could be adopted file-by-file in millions of existing JS projects while Java-style languages could not.

Narrowing: from wide to exact

Union types (string | number) describe values the checker cannot yet pin down. Narrowing is how the compiler follows your runtime logic and refines the type inside each branch — the mechanism that makes unions practical instead of painful.

Flow: a value of type string or number enters a typeof check; inside the if branch the type is string, inside else it is number
Figure 2 — Control-flow analysis. The checker simulates every branch: after the typeof guard, each branch holds the refined type, not the union.

Narrowing with typeof, in, and instanceof

Purpose of the example: show that the same runtime checks you already write in JavaScript are understood by the checker.

function format(value: string | number | Date): string {
  // typeof narrows primitive unions — same operator as plain JS
  if (typeof value === "string") {
    return value.toUpperCase();      // here: string — .toUpperCase exists
  }
  // instanceof narrows class instances
  if (value instanceof Date) {
    return value.toISOString();     // here: Date — .toISOString exists
  }
  return value.toFixed(2);          // here: number — the only survivor
}

What you should see: no casts, no assertions — each branch's type is refined by the check itself. Remove the typeof guard and value.toUpperCase() errors immediately: Property 'toUpperCase' does not exist on type 'number'.

What we learn: in TypeScript you rarely ask "what type is this?" — you write the runtime check and let the checker track it. The union shrinks as the code progresses; by the last line the compiler knows exactly what remains.

Discriminated unions: the professional pattern

Plain unions of object types are ambiguous — how does the checker tell two "shape" branches apart? Give every variant a shared literal kind field, and narrowing becomes exact:

Purpose of the example: model API states so that the compiler forces every branch to be handled — replacing boolean-flag objects that JavaScript code typically uses.

// ❌ The JS habit: flags that can contradict each other.
// { loading: true, error: "..." } is representable — a lie.

// ✓ The TS pattern: one tagged variant is ever inhabited.
type FetchState =
  | { kind: "idle" }
  | { kind: "loading" }
  | { kind: "ok"; data: T }          // payload only exists in this variant
  | { kind: "error"; message: string };

function view(state: FetchState): string {
  switch (state.kind) {
    case "idle":    return "Nothing requested yet";
    case "loading": return "Loading…";
    case "ok":      return `Data: ${JSON.stringify(state.data)}`; // state.data is legal here
    case "error":   return `Failed: ${state.message}`;
    default: {
      // Exhaustiveness: if a variant is added without a case,
      // THIS line becomes a compile error.
      const never: never = state;
      return never;
    }
  }
}

What you should see: inside case "ok", state.data type-checks; in every other case it would be an error — you cannot read data from an idle state. Add a fifth variant { kind: "cancelled" } to the union and the default branch errors until you handle it.

What we learn: the never exhaustiveness check turns "did I update every switch?" from a code-review worry into a compile error. This single pattern eliminates the largest class of UI state bugs (impossible flag combinations) and is used by every serious TypeScript codebase.

strictNullChecks: the null problem, contained

In JavaScript, null/undefined flow through any expression, and the "cannot read property of undefined" crash is a rite of passage. With strictNullChecks (part of strict), those values stop being assignable to other types: string is never undefined; only string | undefined can be.

A value typed string or undefined flows through a guard; after the early return the type is string, while the unguarded path errors
Figure 3 — The guard removes undefined from the type, not just from the flow. The crash is prevented at the point of access, not discovered in production.

Handling undefined explicitly

Purpose of the example: show the three legitimate responses to a possibly-missing value, in preference order.

function greet(name: string | undefined): string {
  // Option 1 — guard clause: narrowing does the work
  if (name === undefined) {
    return "Hello, guest";
  }
  return `Hello, ${name.toUpperCase()}`;  // here: string only

  // Option 2 — defaulting: collapse the union yourself
  // const n = name ?? "guest";

  // Option 3 — non-null assertion: "trust me" (last resort)
  // return `Hello, ${name!.toUpperCase()}`;
}

What you should see: uncommenting option 3 while the guard is absent produces code that compiles but crashes when name is undefined — the assertion shifts responsibility entirely onto you, and the checker never reminds you again.

What we learn: ! is a review flag, not a tool. Every ! in a codebase is a place where the type system was told to look away; professional teams lint against it outside tests.

readonly and readonly arrays

The type system can also encode change permissions: readonly members forbid writes at check time (enforced only at compile time — erasure applies), and readonly T[] forbids mutating methods like push:

Purpose of the example: make immutability intentions explicit in function contracts.

interface Config {
  readonly port: number;        // cannot be reassigned after creation
  hosts: readonly string[];     // cannot be pushed/spliced
}

function audit(config: Config): string[] {
  // config.hosts.push("evil")   // ✗ TS2540 — cannot assign to 'push'
  return [...config.hosts];      // copy to get a mutable array
}

What you should see: the mutation attempt fails at compile time with TS2540; the spread produces a fresh mutable copy. Note the boundary: readonly is a design-time guarantee — nothing stops a non-TS caller from mutating at runtime, so critical invariants still need runtime enforcement.

Common type-system mistakes

Mistakes to avoid

  • Modeling state with booleans. { loading: boolean; error?: string } permits lying combinations; prefer a discriminated union as shown above.
  • Fighting narrowing with casts. If you reach for as after an if, ask why the check did not narrow — usually the union is not discriminated.
  • Any-ing the escape hatch. any disables the system for everything it touches; the types lesson covers unknown as the safe alternative.
  • Assuming runtime checks exist. Types are erased (see the compiler lesson): a type cannot validate API input — parse and validate at the boundary, then let types describe the verified result.

Practice: reshape a real module

From flags to variants

Take this typical JavaScript shape and rebuild it as a discriminated union with an exhaustive switch:

// Before: four booleans, sixteen combinations, most meaningless
type UploadState = {
  started: boolean; progress?: number;
  done: boolean; error?: string;
};

What you should see: a union of idle, uploading(progress: number), done, and failed(message: string) variants, where the compiler rejects reading progress outside the uploading branch. Then hand the same problem to a colleague in a review — the union makes the valid states self-documenting.

Narrowing drill

Write a function area(shape: Circle | Rect) that narrows correctly without a discriminant field, then break it by adding a third variant, and finally fix it permanently by adding kind. You will feel the exact moment structural guessing stops scaling and discrimination takes over — the core insight of this lesson.