TypeScript Type Guards for Discriminated Unions (Best Practices for Scalable Code)
Source: Dev.to
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 (e.g., 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?
function handleResponse(res: ApiResponse) {
console.log(res.data);
}TypeScript 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 (the discriminator) to identify the type. In our example the discriminator is status.
Now TypeScript can narrow types safely:
function handleResponse(res: ApiResponse) {
if (res.status === "success") {
console.log(res.data);
}
}TypeScript automatically knows that inside the if block 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.
if (res.status === "error") { /* … */ }The condition acts as a type guard.
We can also create custom type guards.
Creating Custom Type Guards
Custom guards improve readability and reusability.
function isSuccess(
res: ApiResponse
): res is { status: "success"; data: string[] } {
return res.status === "success";
}Usage
if (isSuccess(res)) {
console.log(res.data); // `res` is narrowed automatically
}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 a 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
Common discriminator names:
type
kind
status
variantExample
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 (e.g., { type: "triangle" }), TypeScript throws an error immediately, preventing 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
type LoadingState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string };Best Practice 5 – Use Discriminated Unions for UI State
type ApiResponse =
| { 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, define a guard once and reuse it.
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 (e.g., “50 different variants”).
Break them into logical groups.
Example
// Separate concerns
type UserState = { /* ... */ };
type OrderState = { /* ... */ };
type PaymentState = { /* ... */ };This keeps code maintainable.
Common Mistakes Developers Make
Using
anyfunction handle(res: any) { /* ... */ }Why it’s bad: Removes TypeScript safety.
Forgetting Exhaustive Checks
Developers often forget to handle new union cases.
Always use anassertNeverhelper:function assertNever(x: never): never { throw new Error(`Unexpected object: ${x}`); }Mixing Unrelated Unions
type Result = | { type: "user" } | { type: "product" } | { type: "error" };Why it’s bad: Combines unrelated domain concerns.
Solution: Separate them into distinct types.
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
Discriminated unions are heavily used in:
- Angular state management
- Redux / NgRx
- API response modeling
- Domain‑driven design
Final Thoughts
Discriminated unions + type guards are among the most powerful patterns in TypeScript.
They let you 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.