Union Types and Narrowing in TypeScript
Union types let TypeScript describe values with more than one possible type. 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 Is a TypeScript Union Type?
A TypeScript union represents a value that can match any of its member types. Write it 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.
Literal Types
Union members do not have to be broad types like string. A specific value can be a type. The literal type "draft" accepts only that exact string. Use a type alias to name a literal union with a fixed set 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.
For a check you reuse, write a type guard with a predicate such as value is User. An assertion function uses asserts value is User instead and must throw when the value is invalid; code after a successful call can use the narrowed type. The runtime validation guide shows a full guard and explains why its checks must match its declared predicate.
What Is a TypeScript Discriminated Union?
A TypeScript discriminated union gives each case a shared property, such as status, with a distinct literal value. Checking that property tells the compiler which case you have:
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'. The errors identify each switch that needs a new case. A return-type annotation or noImplicitReturns can catch some missing returns, but the never assignment names the missing union member and also works in void functions.
Pitfalls and Debugging
Property 'x' does not exist on type 'A | B'. 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 narrowed let variable may change before a callback runs. TypeScript can preserve narrowing after the last assignment in supported closures, but an assignment in a nested function prevents that guarantee. 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 on only one TypeScript union member?
TypeScript blocks access to a property that only some union members have until a check narrows the value to a member that supports it. 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 TypeScript union the same as an enum?
A TypeScript literal union is not an enum. A literal union is type information that disappears during 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 can TypeScript narrowing stop working inside a callback?
TypeScript can lose narrowing inside a callback when the captured value might change before the callback runs. 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 TypeScript narrowing work on object properties?
TypeScript narrowing works on object properties, such as 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. Calling another function generally leaves the narrowed type in place, even if that function could change the property. TypeScript does not track every possible mutation.
What makes a good discriminant in a TypeScript union?
A useful TypeScript discriminant is a property shared by all union members, with distinct literal values that identify the cases. 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.
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.