TypeScript Type Guards Deep Dive
Master TypeScript type guards: typeof, instanceof, discriminated unions, user-defined predicates, and assertion functions. Narrow types safely and idiomatically.
What you'll learn
- ✓Built-in narrowing with typeof, instanceof, and in
- ✓Discriminated unions and tagged types
- ✓Writing user-defined type predicates
- ✓Assertion functions and never for exhaustiveness
- ✓When narrowing breaks down and how to recover
Prerequisites
- •Basic TypeScript
- •Familiarity with union types
What and Why
A type guard is any expression that lets TypeScript safely narrow a broad type to a more specific one inside a branch of code. Narrowing is the bridge between flexible APIs that accept many shapes and the strict, specific operations you actually want to perform.
Without guards, you would cast everywhere, which discards the safety TypeScript is trying to give you. With them, you write straightforward conditional code and the compiler tracks which subtype you are inside each branch. The payoff is fewer runtime errors, better autocomplete, and code that reads like the domain logic it represents.
Mental Model
TypeScript runs a flow analysis on your code. At each branch, it asks: “given the condition that just succeeded, what subset of the original type is still possible?” Guards are the conditions it understands. Some are built in, like typeof x === 'string'. Others are guards you teach the compiler with predicate or assertion signatures.
The compiler narrows in two directions: into the if branch (the predicate held) and into the else branch (the predicate failed). A well-designed union plus a guard turns a runtime check into a typed dispatch.
Hands-on Example
Start with built-in guards.
function format(x: string | number | Date) {
if (typeof x === 'string') return x.trim();
if (x instanceof Date) return x.toISOString();
return x.toFixed(2); // x is number here
}
The compiler eliminates one possibility per branch. By the final return, only number remains.
For object shapes, prefer discriminated unions.
type Result =
| { kind: 'ok'; value: number }
| { kind: 'err'; message: string };
function render(r: Result) {
if (r.kind === 'ok') return `value=${r.value}`;
return `error=${r.message}`;
}
The kind field is the discriminator. A single string comparison narrows to the matching variant, exposing only the fields that variant has.
When the type does not have a tag, write a predicate.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function isCat(a: Cat | Dog): a is Cat {
return 'meow' in a;
}
function speak(a: Cat | Dog) {
if (isCat(a)) a.meow();
else a.bark();
}
The return type a is Cat is the predicate signature. It tells the compiler what to assume when the function returns true.
Input: string | number | Date
|
typeof x === 'string'
/ \
yes no
string number | Date
|
x instanceof Date
/ \
yes no
Date number <-- exhaustive For exhaustiveness, use the never type. If you handle every variant, the leftover should be never.
function ensure(x: never): never { throw new Error('unreachable'); }
function handle(r: Result) {
switch (r.kind) {
case 'ok': return r.value;
case 'err': return null;
default: return ensure(r);
}
}
If a new variant is added later, the default branch will fail to compile, catching the missing case at build time.
Assertion functions are the throwing variant of predicates.
function assertString(x: unknown): asserts x is string {
if (typeof x !== 'string') throw new TypeError('not a string');
}
function upper(x: unknown) {
assertString(x);
return x.toUpperCase(); // x is string from here on
}
Assertions narrow the rest of the scope, not just an if branch.
Common Pitfalls
The most common bug is a predicate that lies. If isCat returns true for something that does not actually have meow, the compiler trusts you and your code crashes. Predicates are unchecked promises; treat them like unsafe casts in disguise.
Another trap is destructuring before narrowing. Once you pull value out of a union, the compiler has lost the link to the tag and cannot narrow further. Narrow first, then destructure.
typeof null === 'object' and typeof [] is 'object' too. Use Array.isArray or a property check for arrays, and an explicit === null check before object branches.
Be careful with optional chaining inside guards. x?.kind === 'ok' does not narrow x itself, only the comparison. Check x != null first when the variable might be undefined.
Finally, narrowing does not survive across function boundaries. If you pass a narrowed value to a callback, the callback sees the original wide type unless you pass through a predicate or generic.
Best Practices
Prefer discriminated unions over predicate functions. A literal tag is cheap, self-documenting, and lets the compiler narrow without trusting your code.
Keep predicates total. If isCat checks 'meow' in a, also verify the type of a.meow when it matters. Conservative predicates prevent silent failures.
Use assertion functions for invariants that should crash on violation, like config validation. Use predicates for branching logic where both outcomes are normal.
Always add an exhaustiveness check at the end of switches over unions. It is a one-line guarantee that future variants get handled.
Wrap-up
Type guards are how TypeScript stays useful when your data has more than one shape. Built-in guards cover primitives and classes, discriminated unions cover most domain modeling, and predicates plus assertions cover everything else.
Lean on the compiler’s flow analysis instead of fighting it. Model variants with tags, write small honest predicates, and let never catch the cases you forget. The result is code that narrows itself.
Related articles
- TypeScript TypeScript Discriminated Unions
How to model variant types in TypeScript with discriminated unions: design, narrowing, exhaustiveness checks, and real-world patterns.
- TypeScript TypeScript Narrowing and Control Flow Analysis
Learn how TypeScript narrows types through control flow, using typeof, instanceof, in, and discriminated unions to write safer code.
- TypeScript TypeScript Branded Types Tutorial
Use branded types in TypeScript to add nominal safety on top of structural types. Stop mixing up UserIds, emails, and raw strings at compile time.
- TypeScript TypeScript Conditional Types Tutorial
Learn conditional types in TypeScript: distribution, infer, and how to build expressive utility types that adapt to the input shape.