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.
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
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
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.
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.
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
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
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
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.
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
Recordmay read better. - A union you narrow with
asevery time you use it: the narrowing is missing, not the type.