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.
| Technique | When to use |
|---|---|
typeof | Primitives (string, number, boolean, bigint, symbol) |
in | Distinguish object shapes by property presence |
instanceof | Class 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
constlocal 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:
typeofis for primitives. Do not rely on it fornull, 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:
inchecks 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; // falseInterview: Use
instanceoffor class hierarchies and native types. For API JSON, use schema validation —instanceofwill 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
isreturn 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:
assertsis 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: ProfileSenior Interview Summary
What to say
Narrowing proves which union member you have. Use
typeoffor primitives,infor shape differences,instanceoffor classes, discriminatedswitchfor state machines, andis/assertsguards for API boundaries. Pair external data withunknownfirst.
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 localBest 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