Mastering TypeScript Type Guards: Safely Narrowing Discriminated Unions at Runtime
Discover how custom type guards let you safely narrow discriminated unions in TypeScript, reducing runtime errors and eliminating unsafe casts. Follow a step‑by‑step example, trade‑offs, and best practices.
21 Oct 2026, 11:51 UTC

Problem: Unsafe Type Assertions in Discriminated Unions
When working with discriminated unions, developers often resort to as casts to access specific properties. This bypasses the compiler’s checks and can lead to subtle runtime crashes if the actual value differs from the asserted type. The question is: how can we keep the safety guarantees of TypeScript while still accessing the right fields at runtime?
Thesis: Use Custom Type Guards to Perform Runtime Narrowing
A type guard is a function that returns a type predicate – a boolean expression that tells the compiler which type a variable has within a given block. By combining typeof, instanceof, or property checks with a predicate, you can safely narrow a discriminated union without resorting to casts.
1. The Anatomy of a Type Guard
```ts function isCircle(shape: Shape): shape is Circle { return shape.type === "circle"; } ```
- Parameter: The value you want to narrow (here
Shape). - Return type: A predicate
shape is Circlethat tells the compiler the type upon a truthy result. - Runtime check: A simple property comparison that works at runtime.
When the guard returns true, the compiler treats shape as Circle inside that branch.
2. Practical Example: Shape Rendering
Define a discriminated union and a guard for each variant.
interface Circle { type: "circle"; radius: number; }
interface Rectangle { type: "rectangle"; width: number; height: number; }
interface Triangle { type: "triangle"; base: number; height: number; }
type Shape = Circle | Rectangle | Triangle;
function isCircle(s: Shape): s is Circle { return s.type === "circle"; }
function isRectangle(s: Shape): s is Rectangle { return s.type === "rectangle"; }
function isTriangle(s: Shape): s is Triangle { return s.type === "triangle"; }
function render(shape: Shape) {
if (isCircle(shape)) {
console.log(`Circle with radius ${shape.radius}`);
} else if (isRectangle(shape)) {
console.log(`Rectangle ${shape.width}x${shape.height}`);
} else if (isTriangle(shape)) {
console.log(`Triangle base ${shape.base} height ${shape.height}`);
} else {
// The else branch is unreachable if all variants are covered
// but helps satisfy exhaustive checks.
const _exhaustiveCheck: never = shape;
console.error(_exhaustiveCheck);
}
}
Compile with tsc --noEmit to confirm no type errors. At runtime, the guard functions perform a lightweight property check, and the console output reflects the correct shape type.
3. Trade‑Offs and Limitations
- Runtime Overhead: Each guard adds a property lookup. In hot loops this is negligible, but redundant checks should be avoided.
- Complexity for Nested Types: For deeply nested discriminated unions, writing many guards can become verbose. Consider using a single guard that checks the discriminant and delegating to specialized handlers.
- Non‑Class Instances:
instanceofguards only work for class instances. Plain objects require property checks. - Incorrect Predicate Syntax: Returning a predicate that does not match the function’s generic type causes a compile error. Always write
function foo(x: X): x is Ywhen narrowing fromXtoY.
4. Practical Checklist Before Deployment
- Define the discriminant property (e.g.,
type) and ensure it is unique across variants. - Implement one guard per variant using a simple equality check.
- Run
tsc --noEmitto catch any type‑predicate mismatches. - Add
console.loginside each guard to verify runtime behavior. - Consider using exhaustive type checks (the
nevertrick) to catch missing variants during development.
Conclusion
Custom type guards give you a clean, type‑safe way to work with discriminated unions. They keep the compiler’s static guarantees while performing real runtime checks, eliminating the need for risky as casts. By following the pattern above, you’ll write code that is both robust and maintainable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.