Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~27 min· topic 3 of 7

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 (and 0n, -0).
Truthy
Any value that is not falsy. Objects, arrays and non-empty text are truthy.
Early return
Leaving a function inside an if so 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.

format.tswhole filets
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.

Remove what you have handleddiagram
Rendering diagram…

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".

label.tswhole filets
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. 1

    Predict the leftover

    In the first example, delete the whole if (value === null) { ... } block. Predict what the checker says about value.toFixed(1) before you look.

    It reports that value is possibly null: that's the member the checks hadn't removed yet.

  2. 2

    Fix the zero bug

    In the second example, change if (count) in label to if (count != null). Run it and compare the console to before.

    Now label(0) prints 0 items. TypeScript didn't tell you either version was wrong: you choose the meaning.

  3. 3

    typeof null

    In the first example, change the parameter type to string | object | null and add a branch if (typeof value === "object"). Hover the value inside it.

    At runtime typeof null is "object", an old JavaScript mistake. TypeScript knows: inside the branch the value is object | null, so you still need a null check.

Code & diagrams

typeof and early returns narrow step by step

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 nothing
Truthiness removes 0 and empty text too

TypeScript 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, stranger
Equality, Array.isArray and loops

Comparing 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 8

Break 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.

terminal
$ Types panel
── what you'll see ──
index.ts(6,10): error TS18047: 'value' is possibly 'null'.

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 and return is 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 although log() 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 a let variable or parameter keeps it only when it is never reassigned, or (since TypeScript 5.4, in simple cases such as if (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 a const if you need the narrowing inside a callback.

  • ▸

    typeof narrows unknown and any-like wide types too (unknown, the safe "could be anything" type, comes later in the course). Switch statements narrow the same way as if chains. With noUncheckedIndexedAccess (on in this course), arr[0] is T | undefined, so you will narrow array reads often.

Remember this

  1. 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 (like string) 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. 2

    **typeof guards.** At runtime, typeof x gives one of eight words: "string", "number", "bigint", "boolean", "symbol", "undefined", "object" and "function". TypeScript understands comparing it to those words. Inside if (typeof value === "string") the value is a string; after the if returns, the value is whatever remains. This is the first tool for any union of primitives.

  3. 3

    Narrowing by elimination. After an if that returns (an early return), the compiler removes the handled members for the rest of the function. If value was string | number | null and you return for string and for null, then the code after both is known to hold a number. Writing guards that return early keeps code flat and the types precise, which is why many TypeScript codebases prefer them over deep nesting.

  4. 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, undefined and NaN. TypeScript removes null and undefined in the true branch, so if (user) is a common way to say "if we have one". The trap: 0 and "" are falsy too, but they are often valid data. if (count) treats a count of zero like "no count", and if (name) treats an empty name like a missing name. TypeScript does not warn you, because the types are fine; the logic is not.

  5. 5

    Equality narrowing. === compares exactly, and TypeScript uses it: if x === "a" passes, x is "a" inside. Comparing two variables narrows both to the types they share (the types that overlap). The loose == null is the one place where == is idiomatic: it is true for both null and undefined, so if (count == null) removes both at once. After it, count is just number. Most style guides allow == null and forbid every other loose comparison.

  6. 6

    Built-in checks the compiler knows: Array.isArray(x) narrows to an array, typeof narrows primitives, Number.isNaN and similar don't change types. Narrowing also happens on assignment (after x = "a", the type of x is string until it can change) and in loops: in a while (typeof x === "string") loop that converts x, the code after the loop knows x is a number.

  7. 7

    Narrowing has limits, honestly stated. It follows const variables and parameters you never reassign best. Inside a callback (a function you hand to another function to call later, like the one in forEach), the narrowing of an object property such as user.id, or of a let variable 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 a const first (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

01

What is narrowing and how does TypeScript know which branch you are in?

02

Why can if (count) be a bug for a number | undefined?

03

How does == null differ from === null, and why is it the one accepted use of ==?

04

Why did narrowing stop working inside a callback, and how do you fix it?

Practice

01

Write function lengthOf(x: string | string[] | undefined): number that returns the length, or 0 for undefined. Use only checks that TypeScript can narrow with.

02

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"; }

03

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 0 and "". 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 0 and "" trap.

  • I can use == null to catch both null and undefined.

  • I can read // ^? queries to see the narrowed type at a line.