Type narrowing — the mechanism by which TypeScript refines a variable's type based on control flow and runtime checks — is one of the most powerful and most underused features in the language. A 2025 formal benchmark study published on arXiv introduced If-T, a language-agnostic benchmark for type narrowing systems, characterising how implementations handle the challenge of refining type environments along individual control paths (arxiv.org/abs/2508.03830). The research highlights that discriminated unions and type guards represent the most practical and ergonomic approach to type narrowing in TypeScript — yet most production codebases only scratch the surface of what these patterns can do.
Image: Typescript fullstack logo — remojansen (MIT), via Wikimedia Commons
What Are Discriminated Unions?
A discriminated union — also called a tagged union or algebraic data type — is a union type where every member shares a common literal property: the discriminant. TypeScript uses this shared property to narrow the union to a specific member in each branch of conditional code.
Consider a typical API response pattern:
type ApiResult<T> =
| { status: 'success'; data: T }
| { status: 'error'; message: string; code: number }
| { status: 'loading' };
function handleResult<T>(result: ApiResult<T>): void {
if (result.status === 'success') {
// TypeScript knows: result.data is T
console.log(result.data);
} else if (result.status === 'error') {
// TypeScript knows: result.message and result.code exist
console.error(`Error ${result.code}: ${result.message}`);
}
}
The key structural requirement is that the discriminant must be a literal type — a specific string, number, or boolean value — not a broad primitive like string. Using 'success' rather than string is precisely what enables TypeScript's control flow analysis to narrow the type. Well-designed discriminated unions eliminate entire categories of runtime errors by making certain invalid states simply unrepresentable at the type level.
Type Guards: typeof, instanceof, and Custom Predicates
TypeScript narrows types through type guards — expressions that return a boolean and, when evaluated as true, signal to the type checker that a variable belongs to a more specific type. There are three main forms in practice:
Built-in narrowing with typeof: Works for primitives. typeof value === 'string' narrows value to string within that branch. Covers 'string', 'number', 'boolean', 'bigint', 'symbol', 'object', 'function', and 'undefined'.
Class narrowing with instanceof: Narrows to a specific class. Especially useful in error-handling hierarchies where different error types carry different context properties. if (error instanceof NetworkError) gives you a fully-typed NetworkError in the truthy branch.
User-defined type guard predicates: A function with a return type of the form value is Type:
interface Dog { kind: 'dog'; breed: string }
interface Cat { kind: 'cat'; indoor: boolean }
type Pet = Dog | Cat;
function isDog(pet: Pet): pet is Dog {
return pet.kind === 'dog';
}
function describe(pet: Pet): string {
if (isDog(pet)) {
return `A ${pet.breed}`; // TypeScript knows: pet is Dog here
}
return pet.indoor ? 'Indoor cat' : 'Outdoor cat'; // pet is Cat here
}
User-defined type guards are powerful but carry responsibility: TypeScript trusts your predicate implementation completely. A logic bug in a type guard breaks type safety silently. Keep them simple, unit-test them explicitly, and treat a complex type guard as a signal that the union structure itself may need simplification.
Exhaustiveness Checking with the never Type
One of the most underused benefits of discriminated unions is compile-time exhaustiveness checking — TypeScript can verify at build time that every variant of a union is handled in a switch or if-else chain.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
const _exhaustive: never = shape; // compile error if new variant added
throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);
}
}
If you later add an 'ellipse' variant to the Shape union but forget to add a case 'ellipse' to the switch, TypeScript immediately errors on the never assignment — preventing a silent runtime fallthrough. This is the discriminated union equivalent of exhaustive pattern matching in ML-family languages, and it makes refactoring union types dramatically safer at scale.
Real-World Patterns: State Machines and Event Systems
Discriminated unions are a natural fit for any system where an entity transitions between well-defined states. Two patterns appear repeatedly in production TypeScript:
UI State Machines — making impossible states unrepresentable: Instead of a tangle of booleans (isLoading, isError, hasData) that can silently represent invalid combinations, model the state explicitly:
type FetchState<T> =
| { phase: 'idle' }
| { phase: 'fetching' }
| { phase: 'success'; data: T; timestamp: number }
| { phase: 'failure'; error: Error; retryCount: number };
You can't simultaneously be in the 'success' and 'failure' phases. TypeScript enforces that you check phase before accessing any phase-specific fields. Boolean flag combinations that previously allowed invalid states vanish entirely.
Domain Event Systems: In event-driven architectures, discriminated unions model the event catalogue in a way that makes handler type signatures fully correct and extensible safely:
type AppEvent =
| { type: 'USER_CREATED'; userId: string; email: string }
| { type: 'ORDER_PLACED'; orderId: string; total: number }
| { type: 'PAYMENT_FAILED'; orderId: string; reason: string };
function dispatch(event: AppEvent): void {
switch (event.type) {
case 'USER_CREATED': sendWelcomeEmail(event.email); break;
case 'ORDER_PLACED': reserveInventory(event.orderId); break;
case 'PAYMENT_FAILED': notifySupport(event.orderId, event.reason); break;
default:
const _: never = event; // compile error when new event type added
}
}
Adding a new event type to the catalogue immediately surfaces any unhandled cases at compile time — a significant safety net as the event system grows over months of development.
never-based exhaustiveness checking are one of the most reliable ways to make large TypeScript codebases safe to refactor. When you add or remove a union variant, the compiler immediately shows every call site that needs updating — no grepping, no runtime surprises.| Narrowing Approach | Best Use Case | Exhaustiveness Check | Type Safety Risk |
|---|---|---|---|
| Discriminated union + switch + never | State machines, events, API results | Yes | Low |
| User-defined type guard (is predicate) | External data, class hierarchies | No | Medium — trust your predicate |
| typeof / instanceof | Primitives, class instances | No | Low |
| Type assertion (as Type) | Boundary code, last resort | No | High — bypasses type system |
Common Mistakes That Undermine Type Safety
Even experienced TypeScript developers make avoidable errors when working with discriminated unions and type guards:
- Optional discriminants: If the shared property is optional (
data?: T), TypeScript cannot use it as a reliable discriminant. Make the discriminant required in every union member — that's what makes it a discriminant. - Structural unions without literal discriminants: A union like
{ name: string } | { age: number }has no literal discriminant, so narrowing requires explicit type guards and loses the exhaustiveness guarantee. Adding even a simplekind: 'named' | 'aged'field unlocks full discriminated union behaviour. - Overly complex type guard functions: When a type guard function branches internally or grows beyond a few lines, the union structure is usually the real problem. Simplify the union first.
- Missing the never default: Omitting the exhaustiveness check from a switch over a discriminated union means TypeScript won't warn you when a new variant is added. Two lines of default case code prevent entire categories of silent regressions.
- Deeply nested discriminated unions: Nesting a discriminated union inside another can make narrowing work correctly but reduce readability. Prefer flat union structures with a single top-level discriminant where possible.
Frequently Asked Questions
When should I choose a discriminated union over a class hierarchy?
Discriminated unions are closed — you explicitly enumerate all variants — which makes them ideal when the set of variants is known and owned entirely by your codebase. Class hierarchies (often with a shared base class or interface) are better when variants may be extended by external consumers or when the set of types is genuinely open-ended. For internal application code — UI states, domain events, command/query types — discriminated unions almost always win because exhaustiveness checking and structural clarity more than compensate for the upfront design work.
How do discriminated unions work with Zod or other runtime validation libraries?
Extremely well — and this combination is one of the most powerful patterns in production TypeScript. Libraries like Zod, Valibot, and Arktype allow you to define a discriminated union schema at runtime and derive the TypeScript type from it automatically. The result is a single source of truth — the schema — that provides both runtime validation (at an API boundary, for example) and compile-time type safety through the derived type. Zod's z.discriminatedUnion() specifically requires the discriminant key, mirroring the type-level constraint at the runtime validation level.
How does TypeScript's type narrowing compare to pattern matching in other languages?
TypeScript's approach is structural rather than nominal, requiring no special syntax beyond plain object literal types sharing a property. Languages like Rust, Haskell, and F# have dedicated algebraic data type syntax and exhaustive pattern matching built into the language core — more ergonomic syntactically, and with more formal guarantees. TypeScript achieves similar practical safety through its flow-sensitive type narrowing system and the never-based exhaustiveness pattern. The 2025 If-T benchmark study (arxiv.org/abs/2508.03830) provides a formal framework for comparing type narrowing implementations across languages, including TypeScript.
Bottom Line: Discriminated unions are one of TypeScript's most reliable tools for production safety. When you find yourself writing boolean flag combinations, repeated type assertions, or optional property access chains, treat that as a signal to model the domain with a discriminated union instead. We recommend defaulting to discriminated unions for any state or event modelling in your application, pairing them with never-based exhaustiveness checking in every switch, and unit-testing user-defined type guard predicates explicitly. The upfront design investment pays back many times over as the codebase grows.
Sources & References:
1. If-T: A Benchmark for Type Narrowing. arXiv, 2025. arxiv.org/abs/2508.03830
2. TypeScript Handbook: Narrowing. typescriptlang.org/docs/handbook/2/narrowing.html
3. TypeScript Handbook: Discriminated Unions. typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html
Disclaimer: This article is for informational purposes only. Technology landscapes change rapidly; verify information with official sources before making technical decisions.