Lesson 2 of 9 · 22m
Narrowing: control flow as a type system feature
typeof, in, instanceof, type predicates, and the truthiness bug that ships to production.
A string | null is two types pretending to be one. Every if, switch, and ternary in your function is a chance to split them back apart — TypeScript calls it narrowing, and it's doing more work for you than most people realize. When it works, it feels like the compiler is reading your code; when it doesn't, the cause is almost always one of the handful of rules this lesson covers.
The three workhorses
typeof for primitives, in for object keys, instanceof for classes:
function size(x: string | string[] | { items: string[] } | Date): number {
if (typeof x === "string") return x.length; // x: string
if (Array.isArray(x)) return x.length; // x: string[]
if ("items" in x) return x.items.length; // x: { items: string[] }
return x.getTime(); // x: Date — only thing left
}After each check, the variable's type inside the block is narrowed to what the check proved. Memorize Array.isArray — typeof [] === "object", so plain typeof can't see arrays.
Truthiness narrowing and the bug that ships
function greet(name?: string) {
if (name) return `hi ${name}`;
return "hi stranger";
}if (name) narrows away undefined — but it also swallows "". If an empty string is a legal input (a trimmed username, a 0 retry count, NaN), truthiness narrowing silently eats it. This is the single most common narrowing bug in production code. When 0, "", or NaN are meaningful, narrow on !== undefined, or != null — the loose != null is the one place loose equality is idiomatic, matching null and undefined and nothing else.
Custom predicates: teaching the compiler your checks
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null
&& "id" in v && "email" in v;
}The v is User return annotation is a type predicate: after if (isUser(x)), x is User. Powerful, and a footgun — the compiler trusts the annotation completely. If your predicate lies, every call site gets a wrong type for free. Write predicates as real checks, keep them at trust boundaries, and prefer validating the shape (as above) over checking for a marker.
A subtlety people hit next: narrowing a variable doesn't always survive a function call for mutable state. If you narrow user.name to string and then call something that could reassign user, TypeScript may widen it back. Assign to a const first and the narrowing sticks — another reason to destructure early.
Try this
Write parsePort(v: string | number | undefined): number that returns a port or throws. Do it twice: once with truthiness checks, once with explicit v === undefined and typeof checks. Feed it "", 0, and "8080" and observe which version lies.
Pitfalls
typeof null === "object"— always has been, forever will be. Checkx !== nullfirst, or use!= null.instanceoffails across realms (iframes, workers,vmcontexts): an array from another realm isn't aninstanceof Array.Array.isArrayworks everywhere.- Narrowing is per-variable.
if (obj.ok)narrowsobj.okfor reads, but passingobj.okas an argument doesn't carry the narrowing unless you aliased it first. - Current for TS 5.x;
useUnknownInCatchVariables(covered in the tsconfig lesson) also changes howcatchvariables narrow.