Topics
v5.0.0
On this page · sections

Type Narrowing & Type Guards

Narrowing is how TypeScript proves which member of a union you have after a runtime check. Type guards are functions or expressions that perform that proof for you.

TechniqueWhen to use
typeofPrimitives (string, number, boolean, bigint, symbol)
inDistinguish object shapes by property presence
instanceofClass instances and built-ins (Error, Date, Array)
Discriminant (kind / type / status)Tagged unions — most common in UI state
Type predicates (is)Custom guards for complex validation
Assertion functions (asserts)Throw-on-fail guards that narrow for callers
Equality=== null, === undefined, === "literal"

How narrowing works

Definition

TypeScript performs control-flow analysis: after a check in an if, switch, or early return, the type is refined in each branch.

function format(value: string | number) { if (typeof value === "string") { return value.toUpperCase(); // value: string } return value.toFixed(2); // value: number }

Narrowing is flow-sensitive — assign to a const local when the narrowed value crosses async gaps.

function processSafe(value: string | null) { if (!value) return; const s = value; doAsyncWork().then(() => console.log(s.length)); // ✅ }

Interview: Narrowing follows control flow. Assign to a const local when the narrowed value crosses async gaps.

typeof narrowing

Definition

typeof works for primitive types and function:

function pad(input: string | number) { if (typeof input === "number") { return String(input).padStart(4, "0"); } return input.padStart(4, "0"); }

Limitations — typeof null is "object" (JavaScript quirk):

function badCheck(value: string | null) { if (typeof value === "object") { // null also lands here! } }

Prefer value == null for null checks.

Interview: typeof is for primitives. Do not rely on it for null, arrays, or plain objects.

in narrowing

Definition

The in operator checks property existence and narrows object unions:

type Cat = { meow: () => void }; type Dog = { bark: () => void }; function speak(animal: Cat | Dog) { if ("meow" in animal) { animal.meow(); } else { animal.bark(); } }

Works well when variants have distinct keys. Prefer discriminant switch when a shared literal field exists.

Interview: in checks property presence. For tagged unions, switch on the discriminant instead.

instanceof narrowing

Definition

instanceof checks the prototype chain — useful for classes and built-in types:

function handleError(err: unknown) { if (err instanceof Error) { console.error(err.message); // err: Error } else if (err instanceof TypeError) { console.error("Type error:", err.message); } else { console.error("Unknown error", err); } }

Common built-ins:

function process(value: Date | string) { if (value instanceof Date) { return value.toISOString(); } return new Date(value).toISOString(); }

Caveat: instanceof fails across realms (iframes) and does not work for interface-shaped plain objects.

// ❌ Plain object — not an Error instance const fake = { message: "oops", name: "Error" }; fake instanceof Error; // false

Interview: Use instanceof for class hierarchies and native types. For API JSON, use schema validation — instanceof will not help.

Discriminant narrowing

Definition

When every union member shares a literal discriminant, switch narrows automatically:

type Action = | { type: "add"; value: number } | { type: "remove"; id: string } | { type: "clear" }; function reducer(state: string[], action: Action): string[] { switch (action.type) { case "add": return [...state, action.value]; case "remove": return state.filter((id) => id !== action.id); case "clear": return []; default: { const _exhaustive: never = action; return _exhaustive; } } }

The never assignment in default catches unhandled union members at compile time when action should be exhausted.

Type predicates (is)

Definition

A function returning arg is T tells TypeScript how to narrow:

type Fish = { swim: () => void }; type Bird = { fly: () => void }; function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; } function move(pet: Fish | Bird) { if (isFish(pet)) { pet.swim(); } else { pet.fly(); } }

Runtime validation guard

type User = { id: string; name: string }; function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "id" in value && "name" in value && typeof (value as User).id === "string" && typeof (value as User).name === "string" ); } const raw: unknown = await response.json(); if (isUser(raw)) { console.log(raw.name); // raw: User }

Pair with Zod or similar at API boundaries; hand-written predicates are fine for simple shapes.

Interview: Type predicates bridge runtime checks and compile-time types. The is return type is what makes narrowing work.

Assertion functions (asserts)

Definition

Assertion functions throw when validation fails and narrow the type for all code after the call:

function assertIsUser(value: unknown): asserts value is User { if (!isUser(value)) { throw new Error("Expected User"); } } function processPayload(raw: unknown) { assertIsUser(raw); // raw is User from here on — no if block needed console.log(raw.name); }

Asserting a specific property:

function assertDefined<T>( value: T, message?: string, ): asserts value is NonNullable<T> { if (value === null || value === undefined) { throw new Error(message ?? "Expected defined value"); } } function render(name: string | undefined) { assertDefined(name, "Name is required"); return <h1>{name}</h1>; // name: string }

Interview: asserts is for “throw if invalid, otherwise narrow.” Use at boundaries where failure should halt execution.

Narrowing null and undefined

Definition

function printLength(s: string | null | undefined) { if (s == null) return; // catches both null and undefined console.log(s.length); // s: string }

Strict equality for a single absent value:

function greet(name: string | undefined) { if (name === undefined) return "Guest"; return `Hello, ${name}`; }

Optional chaining does not narrow for later statements:

if (user?.profile) { // user.profile is truthy, but user might still be undefined in nested access console.log(user.profile.bio); }

Assign to a local after narrowing when logic grows:

const profile = user?.profile; if (!profile) return; console.log(profile.bio); // profile: Profile

Senior Interview Summary

What to say

Narrowing proves which union member you have. Use typeof for primitives, in for shape differences, instanceof for classes, discriminated switch for state machines, and is / asserts guards for API boundaries. Pair external data with unknown first.

Narrowing checklist

Primitive union → typeof / === Object variants → in / discriminant switch Classes / Errors → instanceof API JSON → unknown + type predicate or schema parse Exhaustive switch → default: never Async-safe → assign narrowed value to const local

Best mental model

Union → "could be A or B" Narrowing → "compiler, I proved it is A" is → "my function vouches for the type" asserts → "I throw if wrong; trust me after" never → "if I am here, I missed a case"

Practical rule

UI async state → discriminated union + switch External data → unknown → guard → typed Avoid → optional data + optional error on same type catch (e) → unknown → instanceof Error

Last updated on