October 12, 2026Nwankwo Ernest Onyebuchi21 min read
Was this useful?
TypeScript Type Narrowing: Master Discriminated Unions for Flawless Conditional Logic
Every TypeScript codebase eventually meets the same bug: a value that can be one of several shapes, and a conditional that quietly assumes the wrong one. A loading spinner renders while data is still undefined. An error message prints undefined because the "success" branch ran on a failed response. A new event type ships and three switch statements silently ignore it.
TypeScript discriminated unions are the most reliable tool for preventing this class of mistake. Combined with type narrowing, they let the compiler prove which fields exist in each branch of your code, and they warn you the moment a new variant appears that you have not handled.
This guide takes you from the fundamentals of type narrowing to production patterns: switch narrowing, exhaustiveness checking with never, Result types, React UI state, reducers, runtime validation with Zod, and the pitfalls that make narrowing "mysteriously" stop working. Every example is plain TypeScript you can paste into a project or the TypeScript Playground.
What you will learn:
How type narrowing and control flow analysis work
How to model mutually exclusive states with a discriminated union
Real-world patterns: API results, UI state, reducers, and event handlers
How to validate untrusted data at runtime so your types stay honest
Common pitfalls, testing strategies, and a best-practice checklist
What Is Type Narrowing in TypeScript?
Type narrowing is the process by which TypeScript refines a broad type into a more specific one inside a particular block of code. The compiler follows the logic of your program, a technique called control flow analysis, and updates what it knows about a variable as it passes through conditions.
function format(value: string | number): string {
if (typeof value === "string") {
return value.toUpperCase(); // value: string
}
return value.toFixed(2); // value: number
}
Inside the if, value is a string. After it, TypeScript knows the string case has been ruled out, so value must be a number. No type assertion is needed, and the compiler checks you in both branches.
TypeScript recognizes several kinds of checks as narrowing constructs:
Technique
Example
Best used for
typeof guard
typeof x === "string"
Primitives
Truthiness
if (user)
Removing null and undefined
Equality
x === y, x !== null
Literal values, null checks
in operator
"radius" in shape
Distinguishing object shapes by property
instanceof
err instanceof TypeError
Class instances
Type predicate
isUser(x): x is User
Custom reusable checks
Discriminant check
shape.kind === "circle"
Unions of objects with a shared tag
The last row is the focus of this article. It is the most scalable of the techniques, because the check is one cheap property comparison and the compiler can use it for both narrowing and exhaustiveness.
Before discriminated unions, many developers model state with a single object and a pile of optional properties:
type User = { id: string; name: string };
interface RequestState {
isLoading: boolean;
data?: User;
error?: string;
}
function render(state: RequestState): string {
if (state.isLoading) return "Loading…";
return state.data.name; // Error: 'state.data' is possibly 'undefined'.
}
The compiler is correct to complain. Nothing in this type says that data exists when isLoading is false. Worse, the type permits states that should be impossible:
isLoading: true together with a populated data
data and error both set at once
isLoading: false with neither data nor error (is this "idle" or a bug?)
The usual workaround is a non-null assertion (state.data!.name), which silences the error and moves the bug to runtime. The better fix is to change the model so that invalid combinations cannot be represented at all.
What Is a Discriminated Union?
A discriminated union (also called a tagged union) is a union of object types that all share one property, the discriminant, whose type is a different literal in each member.
Three ingredients make narrowing work:
A union type made of object types.
A common property present in every member (often kind, type, status, or tag).
Literal types for that property, so each member has a unique value.
Here is the broken example rewritten correctly:
type User = { id: string; name: string };
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string; retryAfterMs?: number };
Each variant carries exactly the data that is valid for it. data exists only on success. error exists only on error. The impossible combinations from the previous section no longer compile.
Narrowing with if statements
When you compare the discriminant, TypeScript narrows the whole object:
Notice the final return. After the two early returns, the compiler has eliminated success and error, so state is idle | loading. Each check removes one possibility from the remaining set.
Narrowing with switch
A switch on the discriminant is usually the clearest form once you have three or more variants:
function statusLabel(state: RequestState): string {
switch (state.status) {
case "idle":
return "Not started";
case "loading":
return "Loading";
case "success":
return `Loaded ${state.data.name}`;
case "error":
return `Error: ${state.error}`;
}
}
Inside each case, state is narrowed to the matching variant. The function has an explicit string return type and no default, which matters for the next section.
Narrowing destructured values
Since TypeScript 4.6, narrowing also works when you destructure a discriminated union into const bindings:
type Action =
| { kind: "add"; payload: number }
| { kind: "rename"; payload: string };
function apply(action: Action) {
const { kind, payload } = action;
if (kind === "add") {
return payload + 1; // payload: number
}
return payload.toUpperCase(); // payload: string
}
This is convenient, but it has constraints (for example, it works on const destructuring from a parameter or constant that is not reassigned). If narrowing mysteriously disappears, fall back to checking action.kind directly. See the TypeScript 4.6 release notes for the exact rules.
Exhaustiveness Checking with never
Narrowing is only half the value. The other half is that the compiler can tell you when you forgot a case.
After you handle every variant, nothing is left, and the leftover type is never. You can exploit that by assigning the remaining value to a never parameter:
function assertNever(value: never, message = "Unexpected value"): never {
throw new Error(`${message}: ${JSON.stringify(value)}`);
}
function statusLabel(state: RequestState): string {
switch (state.status) {
case "idle":
return "Not started";
case "loading":
return "Loading";
case "success":
return `Loaded ${state.data.name}`;
case "error":
return `Error: ${state.error}`;
default:
return assertNever(state);
}
}
Now add a new variant, say { status: "cancelled" }. The default branch receives a value that is no longer never, and the compiler reports an error similar to: Argument of type '{ status: "cancelled"; }' is not assignable to parameter of type 'never'. The error appears at every switch you forgot to update, which is exactly the list of places you need to review.
Because assertNever throws, it also protects you at runtime if an unexpected value sneaks in from outside your type system (more on that later).
Two other ways to get exhaustiveness
1. An explicit return type and no default. If a function declares a return type that does not include undefined and a switch misses a case, TypeScript reports that the function lacks an ending return statement (error TS2366). This is what the earlier statusLabel example relies on. It is concise, but the error message is less specific than the never approach.
2. An exhaustive lookup table. Mapped types and Record force you to supply every key:
Add a fourth shape and this object stops compiling until you add its label.
Let your linter help
The typescript-eslint project ships a switch-exhaustiveness-check rule that reports switch statements over union types that do not cover every member. It catches the problem during code review and in CI, even in files where you did not write a default branch.
Real-World Pattern 1: The Result Type
Exceptions are invisible in function signatures. A Result type makes failure part of the contract, and a boolean literal works perfectly well as the discriminant:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function parsePort(input: string): Result<number, string> {
const port = Number(input);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { ok: false, error: `Invalid port: ${input}` };
}
return { ok: true, value: port };
}
const result = parsePort(process.env.PORT ?? "3000");
if (!result.ok) {
throw new Error(result.error); // result: { ok: false; error: string }
}
console.log(result.value); // result: { ok: true; value: number }
Callers cannot read value without first proving that ok is true. Make sure strict is enabled in your tsconfig.json (see the strict option reference), because truthiness-based narrowing on boolean discriminants relies on strict null checking.
Generics keep the type reusable across your whole API layer: Result<User, ApiError>, Result<Invoice[], string>, and so on.
Real-World Pattern 2: UI State in React
Asynchronous UI is the textbook use case for discriminated unions, because a screen is always in exactly one state. Reusing the RequestState type from earlier:
function UserCard({ state }: { state: RequestState }) {
switch (state.status) {
case "idle":
return null;
case "loading":
return <p aria-busy="true">Loading…</p>;
case "success":
return <h2>{state.data.name}</h2>;
case "error":
return <p role="alert">{state.error}</p>;
default:
return assertNever(state);
}
}
There are no optional chains, no ! assertions, and no way to render a half-loaded card. When a teammate adds a "stale" state, the default branch fails to compile and points them straight to the component that needs updating.
If you manage shared state across components, the same modeling idea applies to what you store. Our guide on React 19 Signals vs the Context API for global state is a good companion read for deciding where such state should live.
Real-World Pattern 3: Reducer Actions
Redux-style actions and React's useReducer are discriminated unions by convention: every action has a type string and a variant-specific payload.
Each case can only touch fields that exist on that action. Dispatching { type: "added", sku: "A1" } without a quantity fails at the call site, not in production.
Real-World Pattern 4: Typed Event Handlers
Message buses, webhooks, and WebSocket protocols all deal with events that share an envelope but differ in payload. A mapped type can require one handler per event, which is exhaustiveness by construction:
Each handler receives precisely the right event type, and removing or forgetting a handler is a compile error. The built-in Extract utility type does the heavy lifting by picking union members that match a given shape.
Dispatching is the one spot where TypeScript cannot correlate the event with its handler, because handlers[event.type] produces a union of function types. Isolate the unavoidable cast in a single place:
function dispatch(event: AppEvent): void {
const handler = handlers[event.type] as (e: AppEvent) => void;
handler(event);
}
The cast is safe here because the Handlers type already guarantees the match, and it lives in one function you can review once instead of at every call site.
Keeping Types Honest: Runtime Validation
Here is the part many tutorials skip. TypeScript types are erased at compile time. When data arrives from JSON.parse, a fetch response, a webhook, or localStorage, the compiler has no idea what it contains:
const event = JSON.parse(rawBody) as AppEvent; // a promise the compiler cannot verify
An as assertion does not check anything. If the payload says "type": "user.updated", your switch falls into assertNever at runtime, or worse, into a branch that reads a field that does not exist.
The fix is to validate at the boundary and derive the type from the validator. Zod supports discriminated unions directly, and its discriminatedUnion API uses the tag to pick the right schema efficiently:
Now the type and the check come from one source of truth. Everything downstream of parseEvent is both statically typed and runtime-verified.
Practical tip: while you design schemas, you will often start from a sample payload. Paste it into the JSON Formatter & Validator to confirm it is well-formed, then use the JSON to TypeScript Converter to generate a first-draft interface. Replace the generic string tag with a literal type and you have the start of a discriminated union.
Advanced Patterns
Extracting a single variant
Extract lets you name one member of a union without repeating its definition:
type Circle = Extract<Shape, { kind: "circle" }>;
type ShapeKind = Shape["kind"]; // "circle" | "rectangle" | "triangle"
Reusable type predicates
When you need to filter an array of variants, a small factory returns a correctly typed predicate:
function isKind<K extends Shape["kind"]>(kind: K) {
return (shape: Shape): shape is Extract<Shape, { kind: K }> =>
shape.kind === kind;
}
declare const shapes: Shape[];
const circles = shapes.filter(isKind("circle")); // Circle[]
const total = circles.reduce((sum, c) => sum + Math.PI * c.radius ** 2, 0);
Without the predicate, shapes.filter(s => s.kind === "circle") still returns Shape[], and you would need a cast to use radius. TypeScript 5.5 can infer type predicates for simple functions such as x => x !== undefined, but explicit predicates remain the clearest choice for discriminated unions.
Nested and combined discriminants
State machines often need two tags, one for the current state and one for the incoming event. Keep each union flat and narrow them one after another:
type State =
| { phase: "draft" }
| { phase: "submitted"; submittedAt: Date }
| { phase: "approved"; approvedBy: string };
type Transition =
| { type: "submit" }
| { type: "approve"; reviewer: string };
function next(state: State, transition: Transition): State {
if (state.phase === "draft" && transition.type === "submit") {
return { phase: "submitted", submittedAt: new Date() };
}
if (state.phase === "submitted" && transition.type === "approve") {
return { phase: "approved", approvedBy: transition.reviewer };
}
return state; // illegal transitions leave the state unchanged
}
Reading each branch tells you which transitions are legal. Unlisted combinations fall through to a documented default.
switch (true) for complex conditions
TypeScript 5.3 added narrowing support for switch (true), which is useful when branches depend on more than one property. Reach for it sparingly; a plain switch on a single discriminant is easier to read and gets exhaustiveness checking for free.
Common Pitfalls (and How to Fix Them)
1. The discriminant widened to string
This is the most frequent cause of "narrowing doesn't work":
const loading = { status: "loading" }; // inferred as { status: string }
describe(loading); // Error: type 'string' is not assignable to the union of literals
TypeScript widens the literal "loading" to string for a mutable object property. The compiler is following its normal inference rules, so this is working as intended, not a bug. You have three fixes:
const a: RequestState = { status: "loading" }; // annotate
const b = { status: "loading" } as const; // freeze the literal
const c = { status: "loading" } satisfies RequestState; // check and keep the literal
The satisfies operator, introduced in TypeScript 4.9, is often the best choice because it validates the shape without discarding literal information. See the TypeScript 4.9 release notes.
A related trap: if any variant declares its tag as plain string (for example kind: string), the union can no longer be narrowed by kind. Every member needs a literal.
2. A default branch that swallows new variants
switch (state.status) {
case "success":
return state.data.name;
default:
return "Nothing to show"; // hides every future variant
}
This compiles forever, even after you add five new states. If you want a catch-all, make it explicit and deliberate. Otherwise prefer default: return assertNever(state).
3. Narrowing lost in callbacks
Narrowing applies to a specific reference at a specific point in the flow. Inside a callback, TypeScript may not assume that a mutable let variable or a mutable property still holds the narrowed value, because something could have reassigned it. TypeScript 5.4 relaxed this for variables that are not reassigned after the closure is created, but the portable habit is to copy into a const first:
if (state.status === "success") {
const { data } = state;
items.forEach((item) => console.log(item.id, data.name)); // data is stable
}
4. Trusting unvalidated input
As covered above, JSON.parse(x) as Variant is a lie that the compiler happily accepts. Validate at every trust boundary: network responses, webhooks, query strings, storage, and message queues.
5. Using in when a tag would be clearer
Checking "radius" in shape does narrow, but it ties your logic to property names that may later appear in other variants. A dedicated discriminant is explicit and supports exhaustiveness checking. Reserve the in operator for situations where you do not control the types, such as third-party data.
6. Overly large or deeply nested unions
Huge unions with many levels of nesting can slow down type checking and make error messages hard to read. If a union grows past a dozen variants, consider splitting it into logical groups (for example UserEvent | BillingEvent) and composing them at the top level.
Testing Your Narrowing Logic
Discriminated unions shift many bugs from runtime to compile time, so your tests should cover both worlds.
Compile-time tests. Use // @ts-expect-error to assert that invalid code fails to compile. If the code ever starts compiling, the directive itself becomes an error and the test fails:
// @ts-expect-error — "data" does not exist on the loading variant
const invalid: RequestState = { status: "loading", data: { id: "1", name: "Ada" } };
Runtime tests. Cover each variant of every function that branches on a union. A table-driven test makes this cheap:
CI. Run tsc --noEmit (and your linter's exhaustiveness rule) on every pull request. The value of exhaustiveness checking is only realized if the failing build blocks the merge.
Best-Practice Checklist
Enable strict mode in tsconfig.json.
Name the discriminant consistently across your codebase (kind, type, or status), and pick one convention.
Use string literals as tags. Avoid numeric enums and plain string tags.
Put variant-specific fields only on the variants where they are valid.
End every switch with assertNever (or an explicit return type with no default).
Model state as a single union instead of several booleans and optional fields.
Derive types from runtime schemas where data crosses a trust boundary.
Isolate unavoidable casts in one small, documented function.
Enforce exhaustiveness in CI with tsc and a lint rule.
Performance and Runtime Considerations
Discriminated unions cost nothing at runtime. The narrowing happens entirely in the type checker; the emitted JavaScript contains only your ordinary property comparisons. The discriminant itself is a real property on your objects, so it travels with the data when you serialize it, which is exactly what makes validation at the boundary straightforward.
The only measurable cost is compile-time, and only for very large or deeply nested unions, as noted in the pitfalls above.
Frequently Asked Questions
What is a discriminated union in TypeScript?
It is a union of object types that share a common property with a unique literal value in each member. Checking that property narrows the whole object to one specific variant, giving you safe access to that variant's fields.
What is the difference between a union and a discriminated union?
A plain union such as Cat | Fish has no guaranteed shared tag, so you narrow it with in checks or custom type guards. A discriminated union is narrowed by comparing a single property, and a switch over that property can be checked for exhaustiveness.
Why is my discriminated union not narrowing?
The usual causes are a discriminant that was widened to string (fix with a type annotation, as const, or satisfies), a variant whose tag is not a literal type, narrowing lost inside a callback on a mutable variable, or comparing against a value that is not part of the union.
How do I make a switch exhaustive in TypeScript?
Add a default branch that passes the value to a function accepting never, such as assertNever. Alternatively, give the function an explicit return type and omit default, so a missing case triggers error TS2366.
Should I use enums or string literals as discriminants?
String literal unions are the simpler choice for most projects. They need no import, work naturally with JSON, and narrow just as well. Use enums only when you have a specific reason, such as interoperating with existing enum-based code.
Can I use boolean values as discriminants?
Yes. A Result type with ok: true and ok: false is a common and readable pattern, as long as strict mode is on.
Do discriminated unions work with generics?
Yes. Types such as Result<T, E> combine generics with discriminated unions, and narrowing works on the instantiated type at each call site.
Conclusion
Discriminated unions turn conditional logic from "hope each branch is right" into "the compiler checks each branch." The recipe is simple:
Model each state as its own object type with a literal tag.
Narrow by comparing that tag with if or switch.
Close every switch with assertNever so new variants cannot be ignored.
Validate untrusted data at the boundary so the types reflect reality.
Start small. Pick one place in your codebase where booleans and optional fields are fighting each other, such as a data-fetching hook or a form workflow, and refactor it into a union. Once you see the compiler guide you through the change, you will find more places to use the pattern.