Topic 4.3
Narrowing with typeof, Truthiness and Equality
In one line
Narrowing means TypeScript reads your if statements and shrinks a union to the member that must be true inside each branch. typeof, truthiness and === are the three everyday checks.
Think of it like this
A security guard at a door sees a person holding either a staff badge or a visitor pass. After the guard checks "is this a staff badge?", the guard knows which kind of person is in front of them, and can say the right thing for that kind. Nothing about the person changed; the check gave the guard more knowledge. Narrowing is the compiler being the guard: it reads your check and, inside the branch where the check passed, it knows more.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Narrowing
- Making a type smaller inside a branch, because a check proved something about the value.
- Type guard
- A check in your code that the compiler understands and uses to narrow, like
typeof x === "string". - Control-flow analysis
- The compiler following your code branch by branch, tracking what each variable can be at each line.
- Falsy
- A value that counts as false in an
if:false,0,"",null,undefined,NaN(and0n,-0). - Truthy
- Any value that is not falsy. Objects, arrays and non-empty text are truthy.
- Early return
- Leaving a function inside an
ifso the code after it only runs for the other cases. - Strict equality
===, which compares value and kind with no conversion.- Loose equality
==, which converts types before comparing. Use it only for== null.
Step by step
01Check the kind with typeof
format accepts string | number | null. For a string we want capital letters; for a number we want one decimal place.
typeof value === "string" is the check. Inside the if, the Types panel shows value: string, so toUpperCase() is allowed.
function format(value: string | number | null) {
if (typeof value === "string") {
return value.toUpperCase(); // value: string
}
// here value: number | null
}02Remove what you have handled
After the string branch returns, the compiler removes string from the union. null is next: value === null is an equality check, and after its early return only number remains.
This is called narrowing by elimination. Read your function from top to bottom as a list of things you have ruled out.
03The truthiness trap
if (count) removes null and undefined, which is what we usually want. But it also removes 0. A shop with a stock of zero is shown as "no items" even though the number is real information.
If zero is a valid value, test for what you mean: count == null for "missing", or count === 0 for "zero".
function label(count: number | null | undefined) {
if (count) return count + " items"; // 0 is treated as missing
return "no items";
}
function better(count: number | null | undefined) {
if (count == null) return "unknown"; // only null and undefined
return count + " items"; // count: number, zero included
}04Equality narrows both sides
a === b can only be true if the two have a type in common. With a: string | number and b: string | boolean, the only shared member is string, so inside the if both are string.
This is rarely written on purpose, but it explains why comparing with a literal narrows: x === "a" is the same idea with a literal type.
05Narrowing follows assignments, loops and early exits
Array.isArray(x) narrows a string | string[] to the array. A while (typeof x === "string") loop that converts x leaves x as a number after the loop. Throwing also narrows: code after if (!x) throw ... knows x is truthy.
Try it yourself
- 1
Predict the leftover
In the first example, delete the whole
if (value === null) { ... }block. Predict what the checker says aboutvalue.toFixed(1)before you look.It reports that
valueis possiblynull: that's the member the checks hadn't removed yet. - 2
Fix the zero bug
In the second example, change
if (count)inlabeltoif (count != null). Run it and compare the console to before.Now
label(0)prints0 items. TypeScript didn't tell you either version was wrong: you choose the meaning. - 3
typeof null
In the first example, change the parameter type to
string | object | nulland add a branchif (typeof value === "object"). Hover the value inside it.At runtime
typeof nullis"object", an old JavaScript mistake. TypeScript knows: inside the branch the value isobject | null, so you still need a null check.
Code & diagrams
Each ^? asks for the type of value at that exact line. After the string branch and the null branch have returned, only number is left.
Console
(nothing was printed)Expected console output
HI 3.0 nothingTypeScript has no complaint about label: the types are fine. The bug is in the logic, since label(0) says no items. label2 keeps zero because == null only catches null and undefined. greet shows the same trap for empty text.
Console
(nothing was printed)Expected console output
5 items no items no items
none unknown 2 items
Hello, strangerComparing a and b can only succeed when both are strings, so inside the if they are narrowed to string. Array.isArray is a guard built into the lib. The while loop only exits when x is no longer a string.
Console
(nothing was printed)Expected console output
same different
3 2
42 8Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget a case
In the first example, delete the if (value === null) { return "nothing"; } block.
Myth vs fact
Myth
if (x) is the same as checking x is not null.
Fact
It also rejects 0, "", false and NaN. For numbers and strings that may be valid, test == null or compare to the exact value.
Myth
Narrowing changes the value.
Fact
It changes only what the compiler believes about it. The value stays exactly the same; nothing runs differently.
Myth
typeof x === "object" means x is an object.
Fact
typeof null is also "object", so the narrowed type still includes null. Check for null as well.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The checker builds a flow graph for each function: every
if, loop andreturnis a node, and each reference to a variable has a flow node that says which assignments and conditions reached it. The narrowed type of a reference is computed by walking back through that graph. This is why narrowing works across early returns and why very large functions with many branches can make the checker slower. - ▸
Narrowing is not reset by function calls: after
if (user.id) { log(); user.id }the property is still narrowed althoughlog()could have changed it (a known trade-off, TypeScript chooses usability over soundness here). What is lost is narrowing inside a closure (a function that keeps access to the variables around it): the narrowing of an object property is always dropped there, and aletvariable or parameter keeps it only when it is never reassigned, or (since TypeScript 5.4, in simple cases such asif (typeof url === "string") { url = new URL(url); }followed by a closure) when the closure is created after its last assignment and no nested function assigns it. The 5.4 rule is conservative, so check with// ^?when it matters. Rule of thumb: narrow, then copy into aconstif you need the narrowing inside a callback. - ▸
typeofnarrowsunknownandany-like wide types too (unknown, the safe "could be anything" type, comes later in the course). Switch statements narrow the same way asifchains. WithnoUncheckedIndexedAccess(on in this course),arr[0]isT | undefined, so you will narrow array reads often.
Remember this
- 1
The problem this solves: With a union, you can only use what every member has in common. Without a way to tell which member you hold, you can't call a text method on a value that might be a number. Narrowing is the process of turning a wide type (like
string | number | null) into a smaller one (likestring) because of a check in the code. The checks the compiler understands are called type guards. The mechanism that follows them through your function is control-flow analysis: the checker walks your code like a reader, line by line, branch by branch, and keeps track of what each variable's type must be at each point. - 2
**
typeofguards.** At runtime,typeof xgives one of eight words:"string","number","bigint","boolean","symbol","undefined","object"and"function". TypeScript understands comparing it to those words. Insideif (typeof value === "string")the value is astring; after theifreturns, the value is whatever remains. This is the first tool for any union of primitives. - 3
Narrowing by elimination. After an
ifthat returns (an early return), the compiler removes the handled members for the rest of the function. Ifvaluewasstring | number | nulland you return forstringand fornull, then the code after both is known to hold anumber. Writing guards that return early keeps code flat and the types precise, which is why many TypeScript codebases prefer them over deep nesting. - 4
Truthiness narrowing. In JavaScript
if (x)is true for most values and false for exactly these falsy values:false,0,-0,0n,""(empty text),null,undefinedandNaN. TypeScript removesnullandundefinedin the true branch, soif (user)is a common way to say "if we have one". The trap:0and""are falsy too, but they are often valid data.if (count)treats a count of zero like "no count", andif (name)treats an empty name like a missing name. TypeScript does not warn you, because the types are fine; the logic is not. - 5
Equality narrowing.
===compares exactly, and TypeScript uses it: ifx === "a"passes,xis"a"inside. Comparing two variables narrows both to the types they share (the types that overlap). The loose== nullis the one place where==is idiomatic: it is true for bothnullandundefined, soif (count == null)removes both at once. After it,countis justnumber. Most style guides allow== nulland forbid every other loose comparison. - 6
Built-in checks the compiler knows:
Array.isArray(x)narrows to an array,typeofnarrows primitives,Number.isNaNand similar don't change types. Narrowing also happens on assignment (afterx = "a", the type ofxisstringuntil it can change) and in loops: in awhile (typeof x === "string")loop that convertsx, the code after the loop knowsxis anumber. - 7
Narrowing has limits, honestly stated. It follows
constvariables and parameters you never reassign best. Inside a callback (a function you hand to another function to call later, like the one inforEach), the narrowing of an object property such asuser.id, or of aletvariable that is assigned again later, is lost, because the compiler can't know when the callback runs or what changed in between. When that happens, copy the value into aconstfirst (const id = user.id) and narrow the copy. The opposite hole exists too: an ordinary function call does not reset narrowing, even though the call could change a property. TypeScript chooses convenience there, so the checker can be wrong after such a call.
Explain it without notes
What is narrowing and how does TypeScript know which branch you are in?
Why can if (count) be a bug for a number | undefined?
How does == null differ from === null, and why is it the one accepted use of ==?
Why did narrowing stop working inside a callback, and how do you fix it?
Practice
Write function lengthOf(x: string | string[] | undefined): number that returns the length, or 0 for undefined. Use only checks that TypeScript can narrow with.
This function returns the wrong message for a score of 0. Fix it without changing the type: function grade(score: number | undefined) { if (score) { return "scored " + score; } return "absent"; }
Write function describe(x: string | number | boolean) that returns "text", "number" or "flag" using typeof and early returns, with no else. What type does x have in the last line?
Trade-offs
- ↔
Early returns keep code flat and narrow by elimination, but a long chain of them can hide the shape of the function. For a value with many variants, a discriminated union and
switch(Topic 4.5) reads better. - ↔
Checking truthiness is short and common, which is exactly why it causes silent logic bugs for
0and"". Prefer explicit checks for numbers and strings.
Done when you can
I can narrow with
typeof,===and truthiness.I can use early returns to narrow by elimination.
I can list the falsy values and explain the
0and""trap.I can use
== nullto catch bothnullandundefined.I can read
// ^?queries to see the narrowed type at a line.