Type Guards & Narrowing
typeof, instanceof, `in`, predicates, assertions — and which to reach for.
Type Guards & Narrowing
Narrowing is what makes TypeScript usable. Without it, every union would force you to write (x as Whatever) everywhere — defeating the point. With it, you check the type at runtime and TypeScript follows along, narrowing the static type as you go.
What "narrowing" means
You start with a wide type:
function f(x: string | number) {
// x is string | number here
}
You write a check at runtime:
if (typeof x === "string") {
// x is string here — TS narrowed it
} else {
// x is number here — TS narrowed it the OTHER way
}
The else branch is half the magic — TS removes string from the union once you've checked for it. This is control-flow analysis: TS reads your code top-to-bottom and updates the type at every assignment, condition, and early-return.
The five built-in guards
1. typeof — primitives
function f(x: string | number | boolean) {
if (typeof x === "string") /* x: string */;
if (typeof x === "number") /* x: number */;
if (typeof x === "boolean") /* x: boolean */;
}
Works for "string" | "number" | "boolean" | "symbol" | "bigint" | "undefined" | "object" | "function". Note: typeof null === "object" (a JS quirk TS faithfully reproduces).
2. instanceof — class instances
function f(d: Date | string) {
if (d instanceof Date) return d.toISOString();
return d; // d: string
}
Works for any class. Subclasses also pass instanceof of their parent.
3. in — property presence on object types
type Cat = { meow: () => void };
type Dog = { bark: () => void };
function speak(a: Cat | Dog) {
if ("meow" in a) a.meow(); // a: Cat
else a.bark(); // a: Dog
}
Excellent for distinguishing object shapes that don't share a discriminator field.
4. Equality
Direct comparison narrows on literal types:
function f(x: 1 | 2 | 3) {
if (x === 2) /* x: 2 */;
else /* x: 1 | 3 */;
}
This is also how discriminated unions work — if (s.kind === "circle") narrows on the discriminator.
5. Truthy/falsy
function f(x: string | undefined) {
if (x) /* x: string */ ;
}
A null-or-empty-string check that narrows. Be careful with numbers: if (x) excludes 0, so x: 0 | 1 | 2 narrows to 1 | 2 inside the truthy branch — usually a bug.
Custom type predicates: x is T
When the built-ins don't express your check, write a function that returns a type predicate:
interface User { id: number; name: string }
function isUser(x: unknown): x is User {
return (
typeof x === "object" && x !== null &&
"id" in x && typeof (x as Record<string, unknown>).id === "number" &&
"name" in x && typeof (x as Record<string, unknown>).name === "string"
);
}
function handle(raw: unknown) {
if (isUser(raw)) {
raw.name; // raw: User
}
}
The return type x is User does two things:
- The return value is a
boolean(so the caller can use it inif). - When that boolean is
true, TS knowsxis aUserin the calling scope.
You're making a promise to TypeScript. If your predicate returns true for a value that isn't actually a User, you've broken the type system at runtime — and TS won't catch it. Predicates are unsound by definition; treat them like the assertions they are.
Assertion functions: asserts x is T
Same idea, but for functions that throw on failure rather than return false:
function assertString(x: unknown): asserts x is string {
if (typeof x !== "string") throw new Error("not a string");
}
function shout(input: unknown) {
assertString(input);
return input.toUpperCase(); // input: string after the call
}
After assertString(input) returns (didn't throw), TS narrows input to string for the rest of the function. This is the pattern behind node:assert, invariant, runtime schema validators with .parse() (zod), and so on.
A common pitfall: arrays and objects
typeof doesn't distinguish arrays from objects:
function f(x: string[] | { a: string }) {
if (typeof x === "object") /* x is BOTH — useless */;
if (Array.isArray(x)) /* x: string[] — works! */;
}
Array.isArray is the special-cased guard for arrays. TS knows about it and narrows correctly.
Combining guards with the ! postfix
The ! non-null assertion isn't a guard — it's a promise that the value isn't null/undefined. TS believes you and narrows accordingly. Use sparingly, only when you know more than TS:
const el = document.getElementById("root")!; // promise: not null
el.innerHTML = "...";
If you're wrong, you crash at runtime. Prefer a real check (if (el) { ... }) when feasible.
Narrowing with discriminated unions
This is the single best use of narrowing — covered fully in the discriminated-unions lesson, but a quick reminder:
type State =
| { kind: "loading" }
| { kind: "success"; data: User[] }
| { kind: "error"; error: Error };
function render(s: State) {
if (s.kind === "loading") return <Spinner />;
if (s.kind === "error") return <Banner err={s.error} />;
return <List users={s.data} />;
}
The discriminator field's literal type makes equality narrow each branch into a single variant.
When to write a custom predicate
Reach for one when:
- You have a runtime check that the built-in guards can't express (parsing JSON, validating an API response).
- The check is reused in 3+ places — the predicate becomes the canonical "is this a User".
Don't reach for one when:
- A built-in guard works.
typeof/instanceof/in/ equality cover 80% of cases without the predicate footgun. - The check is one-off — inlining it is clearer than naming a function whose body is one line.
When to use an assertion function
When the alternative is a chain of if (!isUser(x)) throw …. Assertion functions read better and remove the boolean-handling boilerplate. Most validation libraries (zod.parse, superstruct, arktype) provide them out of the box.
That's the whole game: narrow the type as you check the value, and trust TypeScript to follow along.
Tools in the wild
3 tools- libraryzodfree tier
Runtime schemas that double as TypeScript guards via `.safeParse` + inferred types.
- libraryvalibotfree tier
Smaller, tree-shakeable alternative to zod with the same TypeScript-first ergonomics.
- serviceTypeScript Playgroundfree tier
Hover variables inside each branch — TS shows the narrowed type instantly.