TypeScript

Unions and narrowing

A value that is one of several things, and how to prove which one it is.

After this lesson you can

  • Declare a value that may be one of several types
  • Narrow a union with typeof, in and a discriminant
  • Use a never check to catch a case you forgot

A union says a value is one of several types.

type Id = string | number;

Until you prove which one it is, you may only do what both allow. That is not the compiler being awkward: at runtime the value really could be either.

Narrowing

A check the compiler understands narrows the type inside that branch.

function label(id: string | number): string {
  if (typeof id === "string") return id.toUpperCase();  // string here
  return id.toFixed(0);                                 // number here
}

typeof, instanceof, in, Array.isArray and a plain comparison against a literal all narrow.

Discriminated unions

The pattern worth knowing by name. Give every member a shared literal field, and checking that field narrows the whole object:

type Result =
  | { status: "ok"; value: number }
  | { status: "failed"; error: string };

function show(r: Result): string {
  if (r.status === "ok") return `got ${r.value}`;
  return `failed: ${r.error}`;   // r.value is not even offered here
}

This is how you model a thing that either worked or did not, without an optional field that is sometimes set and sometimes not.

Try it

A result that either worked or did nottypescript-5
type Result =  | { status: "ok"; value: number }  | { status: "failed"; error: string }; function show(r: Result): string {  switch (r.status) {    case "ok":      return `got ${r.value}`;    case "failed":      return `failed: ${r.error}`;    default: {      const impossible: never = r;      throw new Error(`unhandled: ${String(impossible)}`);    }  }} export function run(): string[] {  return [    show({ status: "ok", value: 42 }),    show({ status: "failed", error: "timeout" }),  ];}
What to look for

Catching the case you forgot

Assigning the narrowed value to never at the end makes the compiler fail when someone adds a new member to the union and forgets this switch:

default: {
  const impossible: never = r;
  throw new Error(`unhandled: ${impossible}`);
}

Add { status: "pending" } to the union and that line stops compiling. That is the compiler finding, at build time, every place a new case needs handling.

Try it yourself

2 visible tests · 2 hidden tests

A Shape is either { kind: "circle", radius: number } or { kind: "rectangle", width: number, height: number }. Implement area(shape: Shape): number. Narrow on kind — do not reach for a cast. Round the circle's area to two decimal places.

  • area({"kind":"rectangle","width":4,"height":5})
  • area({"kind":"circle","radius":2})
Loading editor…

Sign up to check the hidden tests and save your progress. Sign up