Union Types and Narrowing in TypeScript
Union types are where TypeScript starts earning its keep. A union says a value is one of several possibilities; narrowing is how the compiler works out which one you actually have inside a given branch. Together they let you model real states, loading, success, failure, instead of hoping the right fields happen to exist.
This is the third guide in the types topic, and the techniques here get used again at the app's boundaries in unknown, any, and runtime data.
What a Union Type Is
A union is written with | between its members. Until you narrow it, you may only do things every member supports:
function formatId(id: string | number): string {
return id.toUpperCase();
} // tsc output:
// error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'. That restriction is the entire safety model. The compiler refuses the string method because id might be a number, and it will keep refusing until a check rules that case out. The rest of this guide is a tour of the checks it understands.
Literal Types
Union members do not have to be broad types like string. A specific value can be a type: "draft" is the type whose only inhabitant is that exact string. Literal unions are how you type a value with a fixed menu of options:
type ArticleStatus = "draft" | "published" | "archived";
function isLive(status: ArticleStatus): boolean {
return status === "published";
}
console.log(isLive("published"));
// prints: true
isLive("pubished"); // tsc output:
// error TS2345: Argument of type '"pubished"' is not assignable
// to parameter of type 'ArticleStatus'. The typo is caught at the call site, which is the practical difference between a literal union and a plain string. Numbers and booleans form literal types the same way, so 0 | 1 and true are both valid types.
One wrinkle from the inference rules: let status = "draft" widens to string, which no longer satisfies ArticleStatus. A const keeps the literal type, and an as const assertion does the same for values inside objects and arrays.
Narrowing with typeof
The oldest narrowing tool is JavaScript's own typeof operator. TypeScript watches these checks and adjusts the type in each branch:
function formatIdSafely(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase(); // id: string here
}
return id.toFixed(0); // id: number here
}
console.log(formatIdSafely("abc"), formatIdSafely(7.4));
// prints: ABC 7 Inside the if, the union has collapsed to string, so the string method is legal. After the branch returns, only number remains, so no else is needed. This flow-based analysis also follows early returns, ternaries, and switch statements.
typeof covers the primitive kinds: "string", "number", "boolean", "undefined", "function", and a few rarer ones. Its famous blind spot is inherited from JavaScript: typeof null is "object", so a typeof x === "object" check does not rule out null, and TypeScript knows it. Check x !== null as well; plain truthiness also narrows but discards empty strings and zero along with it.
Narrowing with in and instanceof
typeof cannot tell two object shapes apart, since both answer "object". The in operator narrows by asking whether a property exists, checking the whole prototype chain rather than own properties only. A member where the property is optional stays possible in both branches:
type EmailChannel = { address: string };
type SmsChannel = { phone: string };
function describeChannel(channel: EmailChannel | SmsChannel): string {
if ("address" in channel) {
return `email to ${channel.address}`;
}
return `sms to ${channel.phone}`;
}
console.log(describeChannel({ phone: "0400 000 000" }));
// prints: sms to 0400 000 000 instanceof handles values built by constructors, walking the prototype chain the way JavaScript itself does. It shines for class instances and built-ins such as Error or Date.
It normally fails for plain object literals and for data that crossed a serialization boundary, since parsed JSON has no class prototype. The edges cut both ways: a manually assigned prototype or Symbol.hasInstance can make a literal pass, while a genuine instance from another realm can fail the check.
Equality checks narrow too. Comparing against null, undefined, or a specific literal removes members that could never be equal, which is often the shortest path with nullable values from the first-types guide.
Discriminated Unions
The checks so far infer the member from incidental evidence. A discriminated union makes the evidence explicit: every member carries the same literal-typed property, called the discriminant, and one check on it collapses the whole shape:
type ApiResult =
| { ok: true; data: { name: string } }
| { ok: false; error: string };
function render(result: ApiResult): string {
if (result.ok) {
return `Welcome, ${result.data.name}`; // success member here
}
return `Something went wrong: ${result.error}`; // failure member here
}
console.log(render({ ok: false, error: "session expired" }));
// prints: Something went wrong: session expired The ok field is doing real work. One boolean check tells the compiler which member is present, so data and error each become available exactly where they exist. Nobody has to remember which fields belong to which state; the editor's autocomplete simply offers the right ones.
This is the single most useful pattern in application TypeScript. Loading states, form states, permissions, and API responses all fit it, and it beats the alternative, a bag of optional fields, precisely because the type forbids impossible combinations like an error message on a success:
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function stateMessage(state: LoadState<string[]>): string {
switch (state.status) {
case "idle":
return "Choose a filter to begin.";
case "loading":
return "Loading...";
case "success":
return `${state.data.length} items found.`;
case "error":
return state.message;
}
}
console.log(stateMessage({ status: "success", data: ["a", "b"] }));
// prints: 2 items found. Each case narrows to one member, so state.data is only visible under "success". The generic T lets the same four states wrap any payload; the shape of the state machine stays fixed while the data varies.
Exhaustiveness Checking with never
The remaining question about that switch: what happens when someone adds a fifth status next year? You want the compiler to point at every place that fails to handle it, and never is the tool.
never is the type with no values. After a switch handles every member, the value in the default branch has been narrowed to never, and that is checkable:
function stateMessageChecked(state: LoadState<string[]>): string {
switch (state.status) {
case "idle":
return "Choose a filter to begin.";
case "loading":
return "Loading...";
case "success":
return `${state.data.length} items found.`;
case "error":
return state.message;
default: {
const unhandled: never = state;
throw new Error(`Unhandled state: ${JSON.stringify(unhandled)}`);
}
}
} Today this compiles, because every status is handled and state in the default branch really is never. Add a { status: "cancelled" } member and the assignment fails to compile, at every switch that uses the pattern:
// tsc output, after adding a "cancelled" member:
// error TS2322: Type '{ status: "cancelled"; }' is not assignable to type 'never'. That error list is the feature. It is a compile-time inventory of every spot the new state needs handling, which turns a risky change into a mechanical one. A return-type annotation gives partial cover under strictNullChecks or another return-path check like noImplicitReturns, since a missed case makes the function return undefined, but the never assignment names the missing member explicitly and works in void functions too.
Pitfalls and Debugging
Property 'x' does not exist on type 'A | B'. Not a missing property, a missing check. The property exists on one member, and the compiler needs proof of which member you have. Add the appropriate narrowing before the access rather than casting the union away with as.
Narrowing vanishes inside a callback. A check on a let variable does not survive into a function created later, because the variable could be reassigned before the callback runs. Copy the value to a const after narrowing and use the const inside the callback.
The discriminant stopped discriminating. If a switch on status no longer narrows, one member usually renamed or widened the property, for example to plain string. Every member needs the same property name with a literal type; hover the union in an editor and read each member's discriminant to find the drifted one.
Comparing against a widened variable. state.status === kind narrows state not at all when kind is a plain string, though the true branch does narrow kind to the discriminant's literal union. Type the comparison value as the literal union, or declare it const with an initializer that infers a literal, so the check carries type information instead of just running at runtime.
An impossible combination compiles anyway. Usually the model is optional fields rather than a union, so the type allows every combination. Restructure into a discriminated union and the compiler starts rejecting states your UI never intended to represent.
Frequently Asked Questions
Why can't you access a property that only one union member has?
Because before narrowing, the value could be any member of the union, so TypeScript only allows what every member supports. Accessing a member-specific property requires proving which member you have first, using typeof, in, instanceof, a discriminant check, or a type guard.
Is a union the same as an enum?
They solve a similar problem differently. A literal union is pure type information and vanishes at compilation, while a regular enum compiles to a real JavaScript object at runtime; const enums are normally inlined away and ambient enums emit nothing. Most application code reaches for literal unions first; enums stay useful when you want that runtime object.
Why does narrowing stop working inside a callback?
The compiler cannot prove when the callback runs, so narrowing of mutable values often resets inside function boundaries. Since TypeScript 5.4 it can survive into closures created after a parameter or let variable's last assignment, unless a nested function assigns the variable. Copying the value into a const before the callback still narrows most reliably.
Does narrowing work on properties, or only on variables?
It works on property access chains too, such as checking result.ok, provided the parts of the chain are not reassigned between the check and the use. Direct mutation, reassignment, or a closure boundary drops the refinement. An opaque function call generally does not, a deliberate unsoundness, since the callee could mutate the property unseen.
What makes a good discriminant property?
A property present on every member with a literal type that is unique per member. String literals such as status are the common choice, though literal numbers and booleans also work. Keep the property name identical across all members, or the discrimination silently fails to trigger.
Related
- TypeScript Types for the full topic overview
- unknown, any, and Runtime Data for narrowing values that arrive untyped
- Interfaces and Type Aliases for why unions always live in aliases
- TypeScript for the language guide
Sources
-
[1]
Narrowing(typescriptlang.org)
-
[2]
Everyday Types(typescriptlang.org)
Read Next
Why unknown beats any at the boundaries of a TypeScript app, how to validate runtime data such as parsed JSON, and what type assertions actually cost.
Interfaces and type aliases both name shapes in TypeScript. Learn the real differences: extension, declaration merging, what each form can express, and which to default to.
The TypeScript type system from the ground up: the first annotations, interfaces and type aliases, union types and narrowing, and handling unknown runtime data.