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
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" }), ];}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.
Common mistakes
- Optional fields instead of a union. One shape with
ok: boolean,value?anderror?lets every combination through, including success with no value. Both calls in the example after this list compile and print nonsense. - Losing the literal.
let status = "ok"is inferred asstring, so an object built from it no longer matches{ status: "ok"; ... }. The same happens toconst r = { status: "ok", value: 1 }stored before it is used: its property widens tostring. Useconstfor the variable, annotate the object with the union, or addas const. - Reading a field only one member has. On a
Result, is a compile error until you have narrowed to the member. Narrow on the discriminant (or with ) rather than casting.
// One shape with optional fields, instead of a union of two exact shapes.interface LooseResult { ok: boolean; value?: number; error?: string;} function showLoose(r: LooseResult): string { return r.ok ? `got ${r.value}` : `failed: ${r.error}`;} export function run(): string[] { // Both compile: nothing ties value to ok, or error to a failure. return [showLoose({ ok: true }), showLoose({ ok: false, value: 3 })];}What interviewers ask
- What is a discriminated union, and why prefer it to optional fields? A shared literal field on every member; checking it narrows to exactly one shape, so impossible states cannot even be written down.
- How do you make the compiler find every switch you forgot to update?
Assign the value to
never(or pass it to anassertNeverhelper) in the default branch; a new member then fails to compile there. - Union or intersection?
A | Bis one or the other, and you may only use what both have until you narrow;A & Bhas everything from both.
Try it yourself
2 visible tests · 3 hidden testsA Notice is one of three shapes, told apart by kind:
{ kind: "message", from: string, text: string }→`${from}: ${text}`{ kind: "mention", from: string, channel: string }→`${from} mentioned you in #${channel}`{ kind: "reminder", title: string, minutesLeft: number }→`${title} in ${minutesLeft} min`whileminutesLeftis above0, and`${title} is starting now`when it is0or below.
Implement headline(notice: Notice): string. Narrow on kind (a
switch with a never check in the default is the idiomatic shape) —
do not reach for a cast.
headline({"from":"Nino","kind":"message","text":"build is green"})headline({"kind":"reminder","title":"Standup","minutesLeft":10})
Sign up to run the tests, check the hidden ones and save your progress. Sign up