Narrowing and type guards
The checks the compiler understands, writing your own with `x is T`, and why a guard is only as honest as its body.
After this lesson you can
- Narrow with typeof, instanceof, in, Array.isArray and equality
- Write a user-defined type guard and an assertion function
- Explain why truthiness narrowing drops 0 and the empty string
- Say what the compiler does and does not verify about a guard
Narrowing is the compiler following your control flow. After a check it
understands, the type inside that branch is smaller than the declared one,
and after an early return the rest of the function is smaller too.
function size(x: string | string[] | null): number {
if (x === null) return 0; // x: string | string[] below
if (typeof x === "string") return x.length;
return x.length; // x: string[]
}
The built-in checks: typeof (for primitives), instanceof (for classes,
including Date and Error), in (does the object have this property),
Array.isArray, and comparison with a literal or with null.
Your own guards
When the check is more than one expression, put it in a function whose
return type is a type predicate, x is T. A true result then narrows
at the call site exactly as typeof would:
interface Cat { kind: "cat"; meows: boolean }interface Robot { serial: string } // A user-defined type guard: the return type says what a true result proves.function isRobot(x: Cat | Robot): x is Robot { return "serial" in x;} function describe(value: Cat | Robot | Date | string[]): string { if (Array.isArray(value)) return `list of ${value.length}`; if (value instanceof Date) return `date ${value.toISOString().slice(0, 10)}`; if (isRobot(value)) return `robot ${value.serial}`; return value.meows ? "a cat that meows" : "a quiet cat";} export function run(): string[] { return [ describe(["a", "b"]), describe(new Date(Date.UTC(2024, 0, 15))), describe({ serial: "R2" }), describe({ kind: "cat", meows: false }), ];}An assertion function is the throwing variant: asserts x is T means
"if this returns at all, x is a T", so the code after the call is narrowed.
function assertIsString(x: unknown): asserts x is string {
if (typeof x !== "string") throw new TypeError("expected a string");
}
The compiler trusts the predicate
A type guard's body is never compared with its return type. Write a guard
that says x is string and returns true for numbers, and every caller
narrows wrongly:
// The compiler trusts a type guard's verdict. It never checks the body.function isText(x: unknown): x is string { return x !== null; // wrong: lets numbers through} function shout(x: unknown): string { if (isText(x)) return x.toUpperCase(); return "not text";} export function run(): string[] { const out: string[] = []; for (const v of ["hi", null, 42]) { try { out.push(shout(v)); } catch (e) { out.push(String(e)); } } return out;}So a guard is a small piece of code that deserves its own tests: it is the one place where a mistake silently switches the checker off for everything downstream.
Truthiness is a blunt check
if (value) narrows away null and undefined, but also 0, "",
false and NaN, which are often real values:
function label(count: number | undefined): string { if (count) return `${count} items`; // 0 is falsy, so it lands below return "count unknown";} function labelFixed(count: number | undefined): string { if (count !== undefined) return `${count} items`; return "count unknown";} export function run(): string[] { return [label(3), label(0), labelFixed(0), labelFixed(undefined)];}Compare against what you actually mean to exclude: !== undefined,
!== null, or != null for both.
Common mistakes
typeof x === "object"as an object check.typeof nullis also"object", so"price" in xon anullthrows aTypeError. Checkx !== nullfirst.- A guard that checks too little.
"price" in xproves the key exists, not that its value is a number;{ price: "5" }passes and+concatenates. - Truthiness where
0or""is valid. Shown above:label(0)says "count unknown". - Narrowing lost in a callback. A narrowed property (
order.note), or aletthat is assigned again later, is not narrowed inside a callback, because by the time the callback runs the value may have changed. Copy it into a first.
Exercise · Pro
Count the items that really have a price
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade