TypeScript Discriminated Unions: Safer States, APIs, and Events
Discriminated unions let you model data that can be one of several distinct shapes, where each shape has a unique marker that TypeScript can use to determine which variant you're working with. This pattern gives you precise type narrowing based on a common property, turning what would be loose runtime checks into compile-time guarantees. Introduced as a core feature in TypeScript 2.0, discriminated unions make it straightforward to represent choices and variants in your types, for example when handling different message types, multiple form states, or varied API responses.
Instead of relying on loose type assertions or unsafe type guards, you establish a clear discriminator property that TypeScript recognizes and uses to narrow types automatically. This approach eliminates entire classes of runtime errors, makes your intentions explicit in the type system, and creates code that's both safer and easier to understand.
In this guide, you will learn how discriminated unions work and when they solve real problems, how to build type-safe state machines and event systems using discriminators, and how to handle complex scenarios like nested unions and exhaustiveness checking.
Prerequisites
To follow this guide, you'll need Node.js 18+:
Setting up the project
Create and configure a new TypeScript project with ES module support:
Initialize with ES modules:
Install dependencies:
Next, create a TypeScript configuration file:
This initializes a tsconfig.json file, giving you a solid TypeScript setup. With these steps complete, you now have a modern TypeScript environment ready for exploring discriminated unions with immediate code execution capabilities using tsx.
Understanding the type safety problem with variants
Applications regularly need to represent data that comes in multiple forms—API responses that succeed or fail with different structures, UI states that carry different data depending on whether content is loading or displaying, or event systems where each event type has its own payload shape. Without discriminated unions, developers typically use loose unions with optional properties, creating situations where TypeScript can't verify that you're accessing the right fields for the right variant.
Let's examine how this lack of precision creates problems in practice:
Check what TypeScript reports about this code:
Run this code to see it works:
The code executes correctly and TypeScript doesn't report any errors, but this is precisely the problem. Even though we check response.status, TypeScript still treats data and error as potentially undefined because the type system can't connect the status value to which properties should exist. This forces defensive optional chaining throughout the code.
Solving the problem with discriminated unions
Discriminated unions eliminate ambiguity by splitting a type into distinct variants, each identified by a literal value in a common discriminator property. TypeScript recognizes this pattern and automatically narrows the type when you check the discriminator, giving you precise access to variant-specific properties without optional chaining.
The pattern requires three elements: multiple types with a common property, that property using literal types as values, and combining those types into a union. When these elements align, TypeScript's control flow analysis narrows types based on discriminator checks.
Let's refactor the previous example using a proper discriminated union:
Check what TypeScript reports with discriminated unions:
Run this to see discriminated unions working:
The discriminated union version provides complete type safety. When you check response.status === "success", TypeScript narrows response to SuccessResponse, making data available without optional chaining. The same narrowing happens for the error case.
Critically, invalid combinations become impossible to construct. Let's try creating invalid states:
Check what TypeScript reports:
You cannot create an object with status: "success" that only has an error property—TypeScript rejects it because SuccessResponse requires data. This compile-time enforcement prevents entire categories of bugs that would only surface at runtime with the optional property approach.
Understanding discriminator mechanics
Discriminated unions work through TypeScript's control flow analysis, which tracks how code execution paths affect types:
- Pattern recognition: TypeScript identifies the discriminator property (must be the same name across all variants with literal types)
- Type narrowing: When you check the discriminator value, TypeScript eliminates impossible variants from the union
- Property access: After narrowing, only properties from the remaining variant(s) are accessible
The narrowing happens purely during type checking based on your conditional logic. TypeScript analyzes if statements, switch cases, and other control flow constructs to determine which variant applies in each code path. This creates guarantees about object structure without any runtime overhead—the generated JavaScript contains your logic with no additional type checking code.
This differs fundamentally from runtime type checking libraries that validate object shapes at runtime. Discriminated unions provide compile-time guarantees through static analysis, catching errors during development rather than in production.
Building type-safe state machines
Discriminated unions excel at modeling state machines where each state carries different data and permits different transitions. This pattern appears frequently in UI components, async operations, and business logic where an entity moves through distinct phases with phase-specific information.
The discriminator serves as the current state, while each variant's properties represent the data relevant to that state. TypeScript enforces that you can only access state-appropriate data and helps ensure you handle all possible states.
Let's model a file upload flow with distinct states:
Run this to see state-based rendering:
The discriminated union enforces that each state carries exactly the data it needs. Let's see what happens if we try to create an invalid state:
Check what TypeScript reports:
An uploading state must include progress and filename, while a completed state must include the file URL. TypeScript prevents accessing properties from the wrong state—you can't accidentally read fileUrl when the status is "uploading" because that property doesn't exist on UploadingState.
This approach scales naturally as requirements evolve. Adding a new state like PausedState requires adding it to the union and handling it in the switch statement. TypeScript's exhaustiveness checking (which we'll cover shortly) ensures you update all relevant code paths.
Implementing exhaustiveness checking
One of the most valuable features of discriminated unions is exhaustiveness checking—TypeScript's ability to verify that you've handled every possible variant. This catches bugs when new variants are added to a union but existing code isn't updated to handle them.
The standard exhaustiveness check uses a helper function that accepts never as a parameter. If TypeScript can reach this function with a non-never type, it means you haven't handled all cases. Combined with the --strictNullChecks flag, this creates compile-time errors for missing cases.
Let's add exhaustiveness checking to an event system:
Run this to see exhaustive handling:
The assertNever function provides exhaustiveness checking. In the default case, TypeScript expects event to be never because all variants should be handled in the switch cases. If you add a new event type to AnalyticsEvent without adding a case for it, TypeScript produces a compile error showing that event is not never.
Let's see what happens when we add a new event type without handling it:
Check what TypeScript reports:
The error pinpoints exactly where the code needs updating. TypeScript knows that event could be an ErrorEvent in the default case, which violates the never type constraint. This forces you to add handling for the new variant before the code compiles.
This exhaustiveness checking becomes invaluable in large codebases where a single discriminated union might be consumed in dozens of places. Adding a new variant automatically generates compile errors at every consumption site that needs updating, turning potential runtime failures into compile-time tasks.
Final thoughts
Discriminated unions move you away from loose optional properties and runtime uncertainty toward precise compile-time guarantees about which variant you're working with. This eliminates entire classes of defensive checks and invalid state combinations that would only surface as production bugs. Starting with simple two-variant unions and progressively adding complexity through nesting or generic wrappers, discriminated unions adapt to your domain's actual structure while TypeScript enforces correctness at every step.
Because discriminated unions work entirely through static analysis, there's no runtime cost beyond the JavaScript you'd write anyway. The narrowing logic happens during compilation, converting your type-safe conditionals into regular JavaScript control flow. This is particularly valuable in complex state management, where a single mistake in handling variants can cascade into user-facing failures that are difficult to debug without compile-time enforcement.
In practice, discriminated unions transform variant handling from error-prone manual coordination into a structured pattern where the compiler guides you. You define clear variants with explicit discriminators once, and TypeScript ensures every consumption site handles all cases correctly. This produces code that's not only safer but also more maintainable, as adding new variants generates immediate feedback about where updates are needed.
If you want to explore these concepts further, you can examine the TypeScript handbook, which covers additional narrowing techniques and demonstrates how discriminated unions interact with other advanced type system features to build robust applications.