TypeScript Union Types and Narrowing
Model 'one of several' with union types and safely narrow them with typeof, in, and discriminated unions, with live examples.

Real values aren't always one neat type. An ID might be a number from the database or a string from a URL. A form field is a date or it's empty. A network request is loading, or it succeeded, or it blew up. TypeScript has one tool for "it's one of these," the union, and a matching skill for using it safely, called narrowing. Get these two and you can finally type the messy, branchy parts of a real app instead of reaching for any.
A union is "one of these"
You write a union with a | between types. The variable can hold any of them, and only those.
let id: string | number;
id = "abc123"; // fine
id = 42; // also fine
id = true; // Error: boolean is not assignable to string | numberUnions aren't limited to type names. You can union together specific literal values (exact strings or numbers), which is how you say "this can only be one of these few options":
type Status = "loading" | "success" | "error";
let state: Status = "loading"; // ok
state = "done"; // Error: "done" isn't one of the threeThat second example is doing more work than it looks. Status can never hold "done" or any other random string. The compiler now knows the complete set of possibilities, which means it can check you've handled them, and your editor autocompletes the three valid values. This single trick replaces a pile of stringly-typed bugs.
Why TypeScript blocks you until you narrow
Here's the catch that confuses everyone at first. Once a value is string | number, TypeScript won't let you treat it as either one yet:
function format(id: string | number) {
return id.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'
}toUpperCase exists on strings but not numbers. Since id might be a number, calling it would crash at runtime, so the compiler refuses. On a union, you can only touch what's common to every member. The fix isn't to fight the type. It's to prove which one you've got. That proof is narrowing: you write a check, and inside the branch where the check passed, TypeScript shrinks the type to just the matching member.
function format(id: string | number) {
if (typeof id === "string") {
return id.toUpperCase(); // here, id is string — toUpperCase is fine
}
return id.toFixed(2); // here, id must be number — toFixed is fine
}Inside the if, TypeScript knows id is a string. After the if returns, the only possibility left is number, so toFixed works with no extra check. The compiler follows your control flow.
Here's that same control flow as a picture. The typeof check splits the union into one branch per member:
Narrowing is just runtime checks
You're not learning new TypeScript syntax here. typeof, in, ===, and truthiness are all plain JavaScript you already write. TypeScript just watches those checks and updates what it knows about the type in each branch. Good narrowing reads like ordinary defensive code.
The ways to narrow
There are four checks you'll use constantly. Run this and edit it: change the input value and watch which branch wins.
Each branch narrows a little more. typeof handles primitives. A truthiness check (if (!value)) strips out null, undefined, 0, and "" in one go, which is why it's so common at the top of a function. Equality (===) narrows literal unions. An if (status === "error") tells the compiler status is exactly "error" in that block. And the in operator checks whether an object has a given property, which is perfect for telling two object shapes apart.
Quick check
After `if (typeof x === "string") { ... }`, what does TypeScript know about `x` in the `else` branch, if `x` was typed `string | number`?
Discriminated unions: the pattern worth memorizing
The in operator works, but it gets fragile once your objects share properties. The clean way to model "one of several shapes" is a discriminated union: give every member a shared field holding a unique literal, usually called kind, type, or status. That one field is the discriminant, and TypeScript uses it to pick the right shape.
This is the canonical way to model the state of an async request. There's no such thing as data while you're still loading, and no error once you've succeeded, and the types should make those impossible states impossible:
Switch on the discriminant and each case narrows to exactly one member. Inside case "success", state.data is available and typed string[]. Try reaching for state.data in the loading case and the compiler stops you, because a loading request has no data. The impossible state isn't just discouraged, it can't be represented. This pattern shows up everywhere: Redux actions, React reducer state, parser results, anything with distinct modes.
Pick boring discriminant names
Stick to kind, type, or status for the discriminant, and use string literals, not booleans. { ok: true } vs { ok: false } technically discriminates, but a named status like "success" reads better in a switch, scales past two cases, and shows up clearly in error messages.
Exhaustiveness with never
A discriminated union plus switch unlocks one more trick: the compiler can force you to handle every case. The never type holds no values at all, so a value is only assignable to never once you've ruled out every possibility:
function render(state: RequestState): string {
switch (state.status) {
case "loading": return "Spinner...";
case "success": return state.data.length + " items";
case "error": return "Failed: " + state.message;
default:
// If every case is handled, state is `never` here, so this compiles.
const _exhaustive: never = state;
return _exhaustive;
}
}Handle all three cases and the default is dead code that compiles fine. Now add a fourth member to RequestState, say { status: "idle" }, and forget to add its case: state in the default is no longer never, the assignment fails to compile, and you get a red squiggle pointing at the exact gap. It turns "I added a state and forgot to update a screen" from a runtime bug into a compile error.
Wrapping up
A union (A | B) says a value is one of several types. Until you prove which, TypeScript only lets you use what they share. You narrow with the plain JavaScript checks you already know: typeof for primitives, truthiness for null and empty, === for literals, and in for object shapes. For modeling distinct states, reach for a discriminated union, a shared literal field you switch on, and lean on a never default to make the compiler catch every missing case. Together they let you type the branchy parts of an app precisely instead of papering over them.
To go deeper on every narrowing rule the compiler applies, the official Narrowing chapter of the TypeScript handbook is the canonical reference and worth a careful read.
This builds on the shapes you met in arrays, objects and tuples. Next we tackle the feature that lets one function or type work across many types without losing safety: generics.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


