Home DevOps & Cloud Security Software Engineering AI & Machine Learning Web Development Developer Tools Programming Languages Databases Architecture & Systems Design Emerging Tech About
Programming Languages

TypeScript Discriminated Unions and Type Guards Guide

NanoTech Insight
NanoTech Insight Editorial Team
2026-09-25
✅ Sourced from primary references — reviewed by our editorial team against official docs, papers, and industry reports. Learn about our editorial process
TypeScript logo displayed as stacked blue layers on a black background, representing full-stack TypeScript development

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.

TypeScript logo displayed as stacked blue layers on a black background, representing full-stack TypeScript development

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

Abstract representation of state machine transitions and event types in a software system

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.

Key Takeaway: Discriminated unions paired with 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:

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.

typescript type guards discriminated unions type narrowing type safety
NanoTech Insight
Written & Reviewed by
NanoTech Insight Editorial Team
Technology Content Team

This article was researched and written by the NanoTech Insight editorial team, grounded in official documentation, peer-reviewed papers, and reputable industry reports. It is reviewed for accuracy before publication and updated to reflect new releases and changes.

Related Articles

WebAssembly in Production: Real-World Applications in 2026
2026-08-31
REST API Security: OWASP Top 10 Risks and How to Fix Them
2026-08-31
PostgreSQL Performance Tuning: 7 Proven Techniques
2026-08-30
Jenkins CI/CD Pipeline: Best Practices for 2026
2026-08-30
← Back to Home