TypeScript is powerful because it helps us write safer code with strong type checking. One of the most useful patterns for handling complex data structures is Discriminated Unions combined with Type Guards.
If you’re building large frontend apps (Angular, React) or API-driven applications, this pattern makes your code predictable, readable, and type-safe.
In this guide, we'll cover:
• What discriminated unions are
• Why type guards matter
• Best practices for using them in real applications
• Common mistakes developers make
The Problem: Handling Multiple Data Shapes
In many applications, a variable can represent different types of objects.
Example: API response states.
type ApiResponse =
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
This is a union type.
But how does TypeScript know which properties exist?
If we try this:
function handleResponse(res: ApiResponse) {
console.log(res.data);
}
TypeScript throws an error:
Property 'data' does not exist on type 'ApiResponse'
Because not every union member has data.
Solution: Discriminated Unions
A discriminated union uses a common property to identify the type.
In our example:
status
Now TypeScript can narrow types safely.
function handleResponse(res: ApiResponse) {
if (res.status === "success") {
console.log(res.data);
}
}
TypeScript automatically knows:
res is { status: "success"; data: string[] }
This is called type narrowing.
What Are Type Guards?
A type guard is logic that helps TypeScript determine the exact type.
Example:
if (res.status === "error")
This acts as a type guard.
But we can also create custom type guards.
Creating Custom Type Guards
Custom guards improve readability and reusability.
Example:
function isSuccess(res: ApiResponse): res is { status: "success"; data: string[] } {
return res.status === "success";
}
Usage:
if (isSuccess(res)) {
console.log(res.data);
}
Now TypeScript automatically narrows the type.
This is very useful in large applications.
Real World Example: Payment System
Consider a payment system where responses differ.
type PaymentResult =
| { type: "success"; transactionId: string }
| { type: "failed"; error: string }
| { type: "pending"; estimatedTime: number };
Using discriminated union:
function handlePayment(result: PaymentResult) {
switch (result.type) {
case "success":
console.log(result.transactionId);
break;
case "failed":
console.log(result.error);
break;
case "pending":
console.log(result.estimatedTime);
break;
}
}
The type field acts as the discriminator.
Best Practice 1: Always Use a Single Discriminator Property
The most common property names:
type
kind
status
variant
Example:
type Shape =
| { type: "circle"; radius: number }
| { type: "square"; size: number };
Avoid multiple discriminators like:
{ kind: "circle", shape: "circle" }
Stick to one clear property.
Best Practice 2: Use switch Instead of Multiple if Statements
Switch statements improve readability and maintainability.
Bad:
if (shape.type === "circle") {}
if (shape.type === "square") {}
Better:
switch (shape.type) {
case "circle":
break;
case "square":
break;
}
This also enables exhaustive type checking.
Best Practice 3: Use Exhaustive Checks
A powerful TypeScript technique.
function assertNever(x: never): never {
throw new Error("Unexpected type");
}
Example:
switch (shape.type) {
case "circle":
break;
case "square":
break;
default:
assertNever(shape);
}
If a new type is added:
{ type: "triangle" }
TypeScript throws an error immediately.
This prevents silent bugs.
Best Practice 4: Avoid Optional Fields in Union Types
Bad design:
type ApiResponse = {
status: "success" | "error";
data?: string[];
error?: string;
};
This creates unclear states.
Better:
type ApiResponse =
| { status: "success"; data: string[] }
| { status: "error"; error: string };
Now the type system enforces valid states only.
Best Practice 5: Use Discriminated Unions for UI State
This pattern works extremely well for frontend state management.
Example:
type LoadingState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; message: string };
Usage in UI:
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.message;
}
This prevents invalid UI states.
Best Practice 6: Create Reusable Type Guards
Instead of repeating logic everywhere.
Example:
function isError(res: ApiResponse): res is { status: "error"; message: string } {
return res.status === "error";
}
Usage:
if (isError(response)) {
console.log(response.message);
}
Reusable guards improve clean architecture.
Best Practice 7: Keep Union Types Small and Focused
Avoid extremely large unions like:
50 different variants
Break them into logical groups.
Example:
UserState
OrderState
PaymentState
This keeps code maintainable.
Common Mistakes Developers Make
1. Using any
Bad:
function handle(res: any)
This removes TypeScript safety.
2. Forgetting Exhaustive Checks
Developers often forget to handle new union cases.
Always use:
assertNever()
3. Mixing unrelated unions
Bad:
type Result =
| { type: "user" }
| { type: "product" }
| { type: "error" }
Separate domain concerns.
Why Discriminated Unions Are Powerful
They help you:
• Prevent impossible states
• Write self-documenting code
• Catch errors at compile time
• Improve maintainability in large codebases
This is why discriminated unions are heavily used in:
• Angular state management
• Redux / NgRx
• API response modeling
• Domain-driven design
Final Thoughts
Discriminated unions + type guards are one of the most powerful patterns in TypeScript.
They allow you to model real-world state transitions safely, while keeping your code readable and scalable.
If you're building large TypeScript applications, mastering this pattern will significantly improve your code quality and reliability.
Top comments (1)
One thing that always feels a bit weird to me with discriminated unions is when the object shape changes depending on the discriminator.
From a modeling and API perspective, I often find it cleaner to keep a stable structure and vary only the payload type:
This adds more consistency and keeps responses more predictable while still benefiting from TypeScript’s narrowing.
I think this should be considered a good practice.