Unions and narrowing

Model a value that has several shapes, and let the compiler follow your if statements.

05of 14
≈ 13Min read
8Parts

A union is "one of these"

Until the compiler knows which member you have, you can only use what every member has in common. Telling it which one is called narrowing, and it happens through ordinary JavaScript.

and you may only do what all of them allow
type Id = string | number;

function show(id: Id) {
  id.toUpperCase();        // Error: not on number
  return String(id);       // fine for both
}

Narrowing with what you already write

the checker reads your control flow
function show(id: string | number) {
  if (typeof id === "string") {
    return id.toUpperCase();      // id is string here
  }
  return id.toFixed(0);           // and number here
}

if (door) { … }                     // removes null and undefined
if ("days" in item) { … }           // removes the members without that key
if (value instanceof Date) { … }    // narrows to Date
if (Array.isArray(x)) { … }         // to the array member
Narrowing survives assignment, not a closure

A narrowed let is widened again inside a callback, because the compiler cannot know when it runs. Copy it into a const first.

Discriminated unions

One literal field the members do not share — status, kind, type — and the compiler can tell them apart. Every impossible combination disappears: there is no way to write a ready result with no doors, or an error with a list.

the most useful pattern in the language
type Result =
  | { status: "loading" }
  | { status: "ready"; doors: Door[] }
  | { status: "error"; message: string };

function render(result: Result) {
  switch (result.status) {
    case "loading": return "Loading…";
    case "ready": return result.doors.length + " doors";    // doors exists here
    case "error": return result.message;                     // and message here
  }
}

Exhaustiveness

This is the moment types repay the effort: the compiler finds every place that needs a new branch, which is exactly the list you would otherwise be compiling by memory.

so a new member breaks the build, not production
function render(result: Result): string {
  switch (result.status) {
    case "loading": return "Loading…";
    case "ready": return result.doors.length + " doors";
    case "error": return result.message;
    default: {
      const exhaustive: never = result;    // add a fourth status and this errors
      return exhaustive;
    }
  }
}

Optional versus a union with undefined

they are not the same
interface A { note?: string }                 // may be absent
interface B { note: string | undefined }      // must be written, may be undefined

const a: A = {};                              // fine
const b: B = {};                              // Error: property 'note' is missing
Under exactOptionalPropertyTypes they diverge further

With that flag, { note: undefined } is no longer assignable to { note?: string } — absent and present-but-undefined become genuinely different, which is usually what you meant.

Intersections

all of these at once
type Timestamped = { createdAt: Date };
type Door = { name: string };

type StoredDoor = Door & Timestamped;    // has both

// and the one that catches people out:
type Impossible = { a: string } & { a: number };   // a: never

Narrowing what came from outside

A union describes what your program can hold; narrowing is how a value earns its way into one. Between the two, the only unchecked thing left is the boundary itself — which is where a schema library belongs.

unknown plus a union is the honest pair
function parse(input: unknown): Result {
  if (typeof input !== "object" || input === null) return { status: "error", message: "Not an object" };
  if (!("status" in input)) return { status: "error", message: "No status" };

  …
}

When a union is the wrong tool

  • Two shapes that are the same thing with different names: use one shape.
  • Five booleans that could be a union of four states — use the union, that is the point.
  • A union of twenty literals that is really data: an object or a Record may read better.
  • A union you narrow with as every time you use it: the narrowing is missing, not the type.

Practice