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.
- 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.
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.
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:
function show(id: number | string) {if (typeof id === 'string') {return id.toUpperCase();}return id.toFixed(2);}
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.
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.
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.
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].
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 }
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.
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.
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[].
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].
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
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));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 | Bholds either. Until you check, only things safe for both are allowed. - Narrowing: the checker follows your
typeofandkindchecks, 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 runtimethrowtoo. - 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.
function len(v: string | string[]) { return v.length; }Does this type-check? - 2.
xisnumber | string. What isx’s type at the marked line?if (typeof x === 'number') { return x * 2; } // here - 3.
What makes a union a discriminated union?
- 4.
A function ends with
return unreachable(s), whereunreachable(x: never). Every kind is handled. What is the type ofson that line? - 5.
Why is the
throwinsideunreachablestill useful, if the checker proved it can’t be reached? - 6.
For
function first<T>(xs: T[]): T | undefined, what does the checker infer forfirst([true, false])?
References
- [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] The TypeScript team (Microsoft). Narrowing (TypeScript Handbook). typescriptlang.org. www.typescriptlang.org/docs/handbook/2/narrowing.html (checked 2026-10-31)
- [3] The TypeScript team (Microsoft). Generics (TypeScript Handbook). typescriptlang.org. www.typescriptlang.org/docs/handbook/2/generics.html (checked 2026-10-31)
- [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)