Viscosity
Review
Part I · JavaScript for engine builders · Day 4

14. TypeScript II: unions, narrowing and generics

Real values come in several kinds: a number or a string, a circle or a rectangle, an addition or a multiplication. Here is how to label “one of these”, and how the checker follows your if statements to know which one you have. This is the exact shape of every syntax tree in this book.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Write a union type, and narrow it with a typeof or kind check.
  • Model data with several variants as a discriminated union with a kind field.
  • Use an exhaustiveness check so the checker proves every case is handled.
  • Read and write a simple generic function like first<T>.

This or that

Some values honestly come in more than one kind. An id might be a number (42) or a string ('ada'). Labelling it number would be a lie, and so would string. TypeScript lets you say “either”:

function show(id: number | string) {
  return id.toUpperCase();
  // error: Property 'toUpperCase' does not
  // exist on type 'string | number'.
}

The checker is right to complain. If id is 42, there is no toUpperCase to call, and the program would crash.

Intuition

A union is a parcel labelled “a book or a mug”. You can do things that work on both (carry it, weigh it), but before you read it, you have to open it and look. Opening the parcel is an if.

Definition

A union type A | B labels a value that may be an A or a B. Until you check which, the checker only allows operations that are safe for every member of the union[1].

Following the if

You already know the runtime check: typeof. Watch what the checker does with it:

Step throughNarrowing a union
function show(id: number | string) {
if (typeof id === 'string') {
return id.toUpperCase();
}
return id.toFixed(2);
}
What the checker believes id can be at the highlighted lineWHAT THE CHECKER BELIEVES id CAN BE HEREnumber|string
At the top, id is labelled number | string: it could be either. The checker only allows what is safe for both, so id.toUpperCase() would be an error here.
1/5
function show(id: number | string) {
  if (typeof id === 'string') {
    return id.toUpperCase(); // id: string
  }
  return id.toFixed(2);      // id: number
}

(toFixed(2) turns a number into text with two decimal places.) Inside the if, only strings get through, so the checker treats id as a string there. After the if returns, only numbers are left.

Definition

Narrowing is the checker working out a more exact type from a check in your code, like typeof id === 'string'. It follows every if, else and return, so in each region of code it knows which members of the union can still be there[2]. The check itself is ordinary JavaScript that really runs. The narrowing is erased.

Tagged variants

typeof can tell a number from a string, but not a circle from a rectangle: both are just 'object'. So we put a tag on every object.

Intuition

Think of envelopes with a word stamped on the outside: “bill”, “letter”, “postcard”. Read the stamp, and you know exactly what’s inside before you open it.

type Shape =
  | { kind: 'circle'; r: number }
  | { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
  if (s.kind === 'circle') {
    return Math.PI * s.r * s.r;
  }
  return s.w * s.h;
}

type Shape = ... gives a name to a type, like interface does, but it can name a union too. In kind: 'circle', the type is the exact string 'circle' and nothing else. So checking s.kind tells the checker which variant s is: inside the if, s.r is allowed; after it, s.w is.

Definition

A discriminated union is a union of object types that all share one property, the tag (here kind), with a different exact value in each. Checking the tag narrows the whole object to one variant[2].

Try itRead the tag, then the fields
function area(s) {
  if (s.kind === 'circle') {
    return Math.PI * s.r * s.r;
  }
  return s.w * s.h;
}

const ss = [
  { kind: 'circle', r: 1 },
  { kind: 'rect', w: 2, h: 3 },
];
for (const s of ss) {
  console.log(s.kind, area(s));
}

// Try adding { kind: 'tri', b: 2 }.
// Why does area give NaN for it?

The shape of every syntax tree

Here’s why this chapter matters so much for the rest of the book. A program’s code, once a parser has read it, is a tree, and each node in the tree is one of a fixed set of kinds. That is exactly a discriminated union:

type Expr =
  | { kind: 'num'; value: number }
  | { kind: 'add'; left: Expr; right: Expr }
  | { kind: 'mul'; left: Expr; right: Expr }
The expression (1 + 2) * 4 as a tree. The top node has kind mul. Its left child has kind add, whose children are num nodes with values 1 and 2. Its right child is a num node with value 4.leftrightleftrightkind: 'mul'kind: 'add'kind: 'num'value: 4kind: 'num'value: 1kind: 'num'value: 2
A syntax tree is a discriminated union, nested. (1 + 2) * 4 as Expr objects. Each node’s kind says which variant it is; add and mul nodes hold two smaller Expr nodes, so the type refers to itself.

Real JavaScript tools do exactly this. In ESTree, the tree format many JavaScript tools share, every node has a string type field that says which kind of node it is[4]. You’ll walk a tree like this recursively in this chapter’s exercise, and that walk is your first interpreter.

Proving every case is handled

Suppose you add a third shape, { kind: 'tri'; b: number; h: number }, and forget to update area. You want the checker to notice. Here is the standard trick:

function unreachable(x: never): never {
  throw new Error('unhandled case');
}

function area(s: Shape): number {
  if (s.kind === 'circle') {
    return Math.PI * s.r * s.r;
  }
  if (s.kind === 'rect') {
    return s.w * s.h;
  }
  return unreachable(s);
}
// error: Argument of type '{ kind: "tri";
// b: number; h: number; }' is not
// assignable to parameter of type 'never'.

never is the type with no values at all. When every kind has been handled, narrowing leaves nothing for s to be by the last line, so s is never and the call is fine. Forget 'tri', and s could still be a triangle there, so the checker points straight at the gap.

Definition

Exhaustiveness checking makes the checker prove every member of a union is handled: after all the checks, the leftover type must be never[2]. Keep the throw inside unreachable anyway. Types are erased, so if bad data ever does arrive, you get a clear error instead of a wrong answer.

Types with blanks: generics

What is the type of a function that returns the first item of any list? first(xs: number[]): number works only for numbers. Writing the same function again for strings would be silly.

Intuition

A generic is a label with a blank in it: “a box of ____”. Each time you use it, the blank is filled in: a box of numbers, a box of strings. The blank is written T.

function first<T>(xs: T[]): T | undefined {
  return xs[0];
}

const a = first([1, 2]);
// a: number | undefined
const b = first(['x']);
// b: string | undefined

<T> declares the blank. The checker fills it in from each call, so a is labelled a number (or undefined, for an empty list) and b a string. You’ve already met one generic: Array<number> is another way to write number[].

Definition

A generic is a function or type with a type parameter, like T, that is filled in each time it is used. Array<T> is a generic type, and first<T> is a generic function[3].

Under the hood

In Part III, your parser will produce trees typed exactly like Expr, and your interpreter will be a bigger version of this chapter’s evaluate. Later, the instructions of Viscosity’s compiler are modelled the same way: a union of instruction kinds, each tagged. The exhaustiveness check earns its keep there. Add a new instruction kind, and tsc lists every function in the compiler that doesn’t handle it yet.

Your turn

ExerciseYour first interpreter

Write evaluate(e) that computes the value of an expression tree. An Expr is one of: { kind: 'num', value }, { kind: 'add', left, right }, { kind: 'mul', left, right } or { kind: 'neg', arg } (negation: minus its argument). For any other kind, throw an Error. (The runner erases types, so the tests check behaviour; the walkthrough shows the typed version with an exhaustiveness check.)

type Expr =
  | { kind: 'num'; value: number }
  | { kind: 'neg'; arg: Expr }
  | { kind: 'add';
      left: Expr; right: Expr }
  | { kind: 'mul';
      left: Expr; right: Expr };

function evaluate(e: Expr): number {
  // your code here
}

// (1 + 2) * 4
const tree: Expr = {
  kind: 'mul',
  left: {
    kind: 'add',
    left: { kind: 'num', value: 1 },
    right: { kind: 'num', value: 2 },
  },
  right: { kind: 'num', value: 4 },
};
console.log(evaluate(tree));
Extra practiceA generic search

Write the generic function firstWhere(xs, ok): it returns the first item of xs for which the callback ok(x) returns true, or undefined if there is none. Its type is firstWhere<T>(xs: T[], ok: (x: T) => boolean): T | undefined, where (x: T) => boolean is the type of a function that takes a T and returns a boolean.

function firstWhere<T>(
  xs: T[],
  ok: (x: T) => boolean,
): T | undefined {
  // your code here
}

const big = firstWhere([3, 8, 12],
  (n) => n > 5);
console.log(big);

What you learned

  • A union type A | B holds either. Until you check, only things safe for both are allowed.
  • Narrowing: the checker follows your typeof and kind checks, and your returns, to know the exact type in each region of code.
  • A discriminated union tags each variant with a kind. It is the shape of every syntax tree and instruction in this book.
  • Exhaustiveness: end with unreachable(x: never), and the checker proves every case is handled. Keep the runtime throw too.
  • A generic like first<T> has a type parameter that each use fills in.

That’s all the TypeScript you need for now. Next: how to know your code really works.

Check your understanding

0/6
  1. 1.

    function len(v: string | string[]) { return v.length; } Does this type-check?

  2. 2.

    x is number | string. What is x’s type at the marked line?

    if (typeof x === 'number') {
      return x * 2;
    }
    // here
  3. 3.

    What makes a union a discriminated union?

  4. 4.

    A function ends with return unreachable(s), where unreachable(x: never). Every kind is handled. What is the type of s on that line?

  5. 5.

    Why is the throw inside unreachable still useful, if the checker proved it can’t be reached?

  6. 6.

    For function first<T>(xs: T[]): T | undefined, what does the checker infer for first([true, false])?

References

  1. [1] The TypeScript team (Microsoft). Everyday Types (TypeScript Handbook). typescriptlang.org. www.typescriptlang.org/docs/handbook/2/everyday-types.html (checked 2026-10-31)
  2. [2] The TypeScript team (Microsoft). Narrowing (TypeScript Handbook). typescriptlang.org. www.typescriptlang.org/docs/handbook/2/narrowing.html (checked 2026-10-31)
  3. [3] The TypeScript team (Microsoft). Generics (TypeScript Handbook). typescriptlang.org. www.typescriptlang.org/docs/handbook/2/generics.html (checked 2026-10-31)
  4. [4] ESTree contributors. The ESTree Spec (es5.md: Node objects). github.com/estree/estree. github.com/estree/estree/blob/master/es5.md (checked 2026-10-31)