Narrowing with typeof & truthiness
See how TypeScript reads your if statements: typeof, truthiness, and equality checks turn a value that could be several types into one you can use.
- 01Narrow with typeofUse a typeof check so TypeScript knows which type a value has inside an if, and avoid the typeof null trap.
- 02Pick the right empty checkChoose between a truthiness check, === and !==, and != null, and know what each one does with 0 and empty text.
- 03Follow the pathsRead how return and throw change a type for the rest of a function, and check it with a ^? query.
One check, a more precise type
A shop page shows prices. Most prices are numbers, but some arrive as text from a form, like "4.50". So the function that shows a price takes either one. In TypeScript you write that as string | number. The | means "or": the price is a string or a number. A type like this is called a union type (the Union types lesson covers it).
function formatPrice(price: string | number) { return price.toFixed(2);}console.log(formatPrice(3));console.log(formatPrice("4.50"));Line 1 says price is a string or a number. Line 2 calls toFixed(2), a number method that writes a number with two decimal places, like 3.00. TypeScript marks line 2, on purpose here:
Property 'toFixed' does not exist on type 'string | number'means "pricemight be text, and text has notoFixed". A method has to work for every type in the union before TypeScript allows it.
TypeScript is right. Line 4 prints 3.00, but line 5 passes the text "4.50", and the code stops with a TypeError. The fix is a check you already know from JavaScript, typeof:
function formatPrice(price: string | number) { if (typeof price === "number") { return price.toFixed(2); // ^? } return price; // ^?}console.log(formatPrice(3));console.log(formatPrice("4.50"));Line 2 asks whether price is a number. Inside the if, on line 3, it must be, so TypeScript allows toFixed. The query on line 4 shows the type there: price: number. (A comment like // ^? asks the page "what type is the name my ^ points at?", and the answer appears after it. In your editor, you point at the name instead.) Every number returns on line 3, so only text reaches line 6, and the query on line 7 says price: string. Lines 9 and 10 print 3.00 and 4.50.
You didn't write a new type anywhere. TypeScript read your if and worked out a smaller type for each branch. This is called narrowing.
price comes in as string | number. The check on line 2 splits it. Inside the if, only a number is left. After the if, only a string is left, because every number already returned.A letter carrier picks up the next item from her bag. It's either a letter or a parcel. She checks its size once. If it's a letter, it goes through the slot. Otherwise it must be a parcel, so she rings the bell. After that one look, she never wonders again what she is holding.
- In real life: An item that is a letter or a parcel
- In TypeScript:
price: string | number - In real life: She checks its size once
- In TypeScript:
if (typeof price === "number") - In real life: Letters go through the slot
- In TypeScript: Inside the
if,priceis a number - In real life: Whatever is left is a parcel, so she rings the bell
- In TypeScript: After the
if,priceis a string
Narrowing is TypeScript following your checks. A value can start with a wide type, like string | number. After a check such as typeof price === "number", TypeScript knows which types are still possible in each branch, and lets you use only what works for them.
typeof checks
In JavaScript, typeof gives back a short piece of text that names a value's kind (the Checking types reliably lesson covers it). It can give eight answers: "string", "number", "bigint", "boolean", "symbol", "undefined", "object", and "function". TypeScript knows what each answer means, so comparing typeof with one of them narrows the value. A check that narrows is called a type guard.
The table below lists the common checks. Read each row as: this check, then what the value can be inside the if.
| When you write | Inside the if, the value is | Good to know |
|---|---|---|
typeof value === "string" | A string. | The empty text "" is a string too. |
typeof value === "number" | A number. | NaN, which means "not a number", is still a number. |
typeof value === "boolean" | A boolean: true or false. | Nothing surprising here. |
typeof value === "undefined" | undefined. | Use it when a value may be missing. |
typeof value === "function" | A function you can call. | Classes count as functions too. |
typeof value === "object" | An object, an array, or null. | typeof null is "object", so null is still possible. |
Try it on the price formatter. Each button puts a different check on line 2. Watch the list under the code and the type the query on line 4 shows.
function formatPrice(price: string | number) { if (typeof price === "number") { return price.toFixed(2); // ^? } return price;}console.log(formatPrice(3));console.log(formatPrice("4.50"));numberNo errorsprints 3.00, 4.50Inside the if, price can only be a number, so toFixed is allowed. The query on line 4 says price: number. Both calls work.
Only typeof price === "number" leaves a type that has toFixed. With "string", the if holds text instead. With price alone, the if only asks whether price is something other than an empty value like 0 or "". A number and text can both pass, so nothing is ruled out. With "object", nothing can get in, and TypeScript calls that type never: a type with no possible values.
One answer needs care. In JavaScript, typeof null is "object", an old mistake the language kept. TypeScript knows this:
function tagList(tags: string[] | null) { if (typeof tags === "object") { return tags.join(", "); } return "No tags";}console.log(tagList(["tea", "green"]));console.log(tagList(null));Line 1 says a product's tags are a list of text, or null when it has none. Line 2 checks for "object", hoping to find the array. But null passes that check too, so TypeScript marks line 3, on purpose here: 'tags' is possibly 'null' means "tags could still be null here, and you can't read join from null". Line 7 prints tea, green, and line 8 stops with a TypeError. The next section shows a check that does rule out null.
Truthiness checks
An if doesn't need true or false. It turns any value into one. These values count as false and are called falsy: 0, NaN, the empty text "", 0n, null, undefined, and false itself. Every other value is truthy. (Booleans & truthiness covers this.) Checking a value this way is called a truthiness check, and TypeScript narrows with it:
function greet(name: string | undefined) { if (name) { return `Hello, ${name.toUpperCase()}`; // ^? } return "Hello, guest";}console.log(greet("Asha"));console.log(greet(undefined));console.log(greet(""));Line 1 says name is a string or undefined. Line 2 checks if (name). undefined is falsy, so it can't get inside, and the query on line 4 shows name: string. That's why toUpperCase on line 3 is allowed. Line 8 prints Hello, ASHA. Line 9 prints Hello, guest. So does line 10: the empty text is falsy too.
The same kind of check fixes the tag list from the last section:
function tagList(tags: string[] | null) { if (tags) { return tags.join(", "); // ^? } return "No tags";}console.log(tagList(["tea", "green"]));console.log(tagList(null));null is falsy, and every array is truthy, even an empty one. So inside if (tags) the query on line 4 shows tags: string[], and the error is gone. Line 8 prints tea, green and line 9 prints No tags.
The ! operator, which means "not", flips a check. TypeScript follows that too. Here the empty case is handled first:
function greet(name: string | undefined) { if (!name) { return "Hello, guest"; } return `Hello, ${name.toUpperCase()}`; // ^?}console.log(greet("Asha"));Line 2 reads "if there is no name". That branch returns on line 3, so line 5 only runs with a real name, and the query on line 6 shows name: string. Line 8 prints Hello, ASHA.
A truthiness check has one trap. It removes every falsy value, not only the missing ones. Here an order's quantity may be missing:
function itemsLabel(quantity: number | undefined) { if (quantity) { return `${quantity} items`; } return "Quantity missing";}console.log(itemsLabel(3));console.log(itemsLabel(0));TypeScript reports nothing, because the code is allowed. But line 7 prints 3 items and line 8 prints Quantity missing. Zero is a real quantity, yet 0 is falsy, so it goes to the same branch as undefined.
if (quantity) asks "is it truthy?", not "is it a number?". 3 goes into the if. 0 is falsy, so it skips the if with undefined, and an order of 0 items says Quantity missing.When 0 or "" is a real answer, check for the missing value itself. That's an equality check, which the next section covers:
function itemsLabel(quantity: number | undefined) { if (quantity !== undefined) { return `${quantity} items`; // ^? } return "Quantity missing";}console.log(itemsLabel(3));console.log(itemsLabel(0));Line 2 now asks quantity !== undefined. The query on line 4 still shows quantity: number, so the type is just as precise. But 0 stays in the if: line 9 prints 0 items.
Equality checks
TypeScript also narrows with the equality operators ===, !==, ==, and != (Equality & sameness explains the difference between them). Comparing with an exact value is the clearest way to rule out null:
function searchFor(query: string | null) { if (query === null) { return "Nothing to search"; } return `Searching for ${query.trim()}`; // ^?}console.log(searchFor(" tea "));console.log(searchFor(null));Line 1 says query is text or null, for "the visitor hasn't searched yet". Line 2 checks query === null, and that branch returns on line 3. So on line 5 query can only be text: the query on line 6 shows query: string, and trim(), which removes spaces at both ends, is allowed. Line 8 prints Searching for tea and line 9 prints Nothing to search.
Some values can be both null and undefined. A strict check for one of them leaves the other:
function nicknameOf(nickname: string | null | undefined) { if (nickname !== null) { return nickname.toUpperCase(); } return "No nickname";}console.log(nicknameOf("Ash"));console.log(nicknameOf(undefined));Line 1 says a nickname is text, null, or undefined. Line 2 rules out null only. TypeScript marks line 3, on purpose here: 'nickname' is possibly 'undefined'. Line 7 prints ASH, and line 8 stops with a TypeError. The loose != fixes it, because it treats null and undefined as equal:
function nicknameOf(nickname: string | null | undefined) { if (nickname != null) { return nickname.toUpperCase(); // ^? } return "No nickname";}console.log(nicknameOf("Ash"));console.log(nicknameOf(undefined));console.log(nicknameOf(null));Line 2 now says nickname != null. That one check removes both, and the query on line 4 shows nickname: string. Line 8 prints ASH, and lines 9 and 10 both print No nickname. This is the one place many teams use != on purpose.
You can also compare two values. If they're equal, they must share a type:
function sameSetting(saved: string | number, picked: string | boolean) { if (saved === picked) { return saved.toUpperCase(); // ^? } return "Different";}console.log(sameSetting("dark", "dark"));console.log(sameSetting(1, true));Line 1 takes a saved setting that is text or a number, and a picked one that is text or a boolean. Line 2 checks that they're the same value. The only type both can be is string, so the query on line 4 shows saved: string, and toUpperCase is allowed. Line 8 prints DARK and line 9 prints Different.
The table below compares the checks for a value that may be missing. Read each row as: this check, what it rules out, and what happens to 0 and "".
| The check | What it rules out | What happens to 0 and "" |
|---|---|---|
if (quantity) | Every falsy value: undefined, null, 0, "", NaN, and false. | They skip the if, together with undefined. |
if (quantity !== undefined) | Only undefined. | They stay in the if branch, like any other number. |
if (quantity !== null) | Only null. | They stay in the if branch. undefined stays too. |
if (quantity != null) | Both null and undefined. | They stay in the if branch. |
Now write a check yourself. Line 3 reads query.trim(), so line 2 has to rule out null first. Type a check in the box and watch the list under the code as you type.
function searchFor(query: string | null) { if (query.length > 0) { return `Searching for ${query.trim()}`; } return "Nothing to search";}console.log(searchFor(" tea "));console.log(searchFor(""));console.log(searchFor(null));query.length > 02 errors: lines 2 and 3prints Searching for tea, Nothing to search, then stops with a TypeErrorquery.length reads from query before anything has ruled out null, so TypeScript marks lines 2 and 3: 'query' is possibly 'null'. When it runs, the first two calls work, and searchFor(null) stops with a TypeError.
query.length > 0 reads from query before anything checks it, so it fails on line 2 as well. query, query !== null, query != null, and typeof query === "string" all work. They differ only for the empty search: the truthiness check sends it to Nothing to search, and the others let it in. query !== "" rules out the wrong thing, so null still gets through.
TypeScript follows every path
A check doesn't only narrow inside its own if. TypeScript follows every path the code can take, from top to bottom. When a branch ends with return, the rest of the function knows that branch's types are gone. This is called control flow analysis: "control flow" is the order in which code runs.
function discountLabel(discount: string | number | undefined) { if (discount === undefined) { return "No discount"; } if (typeof discount === "string") { // ^? return `Coupon ${discount.toUpperCase()}`; } return `${discount}% off`; // ^?}console.log(discountLabel(undefined));console.log(discountLabel("tea10"));console.log(discountLabel(20));Line 1 takes a discount that is a coupon code (text), a percent (a number), or undefined for none. Line 2 handles undefined and returns. So by line 5, the query on line 6 shows discount: string | number. Line 5 handles text and returns on line 7. So line 9 only sees a number: the query on line 10 says discount: number. Lines 12 to 14 print No discount, Coupon TEA10, and 20% off.
if that returns takes its type away from the rest of the function: undefined after line 2, then string after line 5. Line 9 only sees a number.Returning early like this is the most common way to narrow. Here a cart badge needs a signed-in user. Line 1 is a type alias: it gives the name User to an object type with a name and a number of cartItems.
type User = { name: string; cartItems: number }; function cartBadge(user: User | null) { if (user === null) { return "Sign in"; } return `Cart: ${user.cartItems} items`; // ^?}console.log(cartBadge({ name: "Asha", cartItems: 2 }));console.log(cartBadge(null));Line 3 takes a User or null. Line 4 handles null, and line 5 returns. So on line 7 the query on line 8 shows user: User. Line 10 prints Cart: 2 items and line 11 prints Sign in.
What matters is that line 5 ends the path. Try other things on line 5 and watch the type on line 7:
type User = { name: string; cartItems: number }; function cartBadge(user: User | null) { if (user === null) { return "Sign in"; } return `Cart: ${user.cartItems} items`; // ^?}console.log(cartBadge({ name: "Asha", cartItems: 2 }));console.log(cartBadge(null));UserNo errorsprints Cart: 2 items, Sign inreturn ends the function, so a null user never reaches line 7. TypeScript removes null from the type there: the query says user: User. A visitor sees Sign in.
return and throw both end the path, so line 7 sees only a User. A console.log doesn't, so null flows on to line 7 and TypeScript marks it. The same following works inside ? : and after &&: the part after a check sees the narrower type.
A search box on a real page
In the browser, document.querySelector("input") looks for an <input> on the page. It gives back HTMLInputElement | null: the input, or null when there isn't one. So code that reads a search box starts with a check:
function searchText(box: HTMLInputElement | null) { if (!box) { return "No search box on this page"; } const text = box.value.trim(); // ^? if (text === "") { return "Type something to search"; } return `Searching for ${text}`;} const searchInput = document.createElement("input");searchInput.value = " green tea ";console.log(searchText(searchInput));console.log(searchText(null));Line 1 takes the box, or null. Line 2 returns early when there's no box. On line 5, the query on line 6 shows box: HTMLInputElement, so reading box.value is allowed. Line 7 compares the text with "" to catch an empty search. Lines 13 and 14 make an input and fill it, as if the visitor typed "green tea" with extra spaces. Line 15 prints Searching for green tea, and line 16 prints No search box on this page.
Each check sits right where the value is used, and TypeScript follows every one. Now sort some one-line functions: does TypeScript accept each one?
function show(price: string | number) { return price.toFixed(2); }function show(price: string | number) { return typeof price === "number" ? price.toFixed(2) : price; }function search(query: string | null) { return query.trim(); }function search(query: string | null) { return query ? query.trim() : ""; }function tagList(tags: string[] | null) { if (typeof tags === "object") return tags.join(); }function shout(nickname: string | null | undefined) { if (nickname != null) return nickname.toUpperCase(); }function shout(nickname: string | null | undefined) { if (nickname !== null) return nickname.toUpperCase(); }function label(quantity: number | undefined) { if (quantity !== undefined) return quantity.toFixed(0); }
Sort each function by what TypeScript says about it. Each one is checked on its own.
Common mix-ups
- "Narrowing changes the value." No. The value stays exactly as it was. Only TypeScript's idea of its type changes, and your check is plain JavaScript that runs as usual.
- "I need a new annotation to narrow a value." No. TypeScript reads the
typeof,if, and===checks you already write. - "
typeof value === "object"means it's a real object." No.typeof nullis"object"too, sonullis still possible. - "
if (value)only removesnullandundefined." No. It removes every falsy value, including0and"", which may be real answers. - "
!== nullalso rules outundefined." No. The strict check rules out onlynull. Use!= nullto rule out both. - "TypeScript forgets my check after the
if." No. If theifreturns or throws, the rest of the function keeps the narrower type.
Practice exercises
Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
What type does label have on line 5? Guess first. Then point at it, or press Edit & run and add a // ^? line under it.
function shortLabel(label: string | number) { if (typeof label === "string") { return label.slice(0, 3); } return label;}console.log(shortLabel("Green tea"));function shortLabel(label: string | number) { if (typeof label === "string") { return label.slice(0, 3); } return label; // ^?}console.log(shortLabel("Green tea"));Every string returns on line 3, so only a number reaches line 5. The query on line 6 shows label: number.
One of these lines gets an error. Which line will TypeScript mark?
function totalText(total: string | number) {
if (typeof total === "string") {
return total.toUpperCase();
}
return total.toUpperCase();
}function totalText(total: string | number) { if (typeof total === "string") { return total.toUpperCase(); } return total.toFixed(2);}console.log(totalText(7));On line 5 only a number is left, and numbers have no toUpperCase: Property 'toUpperCase' does not exist on type 'number'. A number method like toFixed(2) fixes it, and the call prints 7.00.
TypeScript marks line 2. Add a typeof check so both calls work and both print two decimal places. What does formatPrice("4.5") print then?
function formatPrice(price: string | number) { return "$" + price.toFixed(2);}console.log(formatPrice(3));console.log(formatPrice("4.5"));function formatPrice(price: string | number) { if (typeof price === "string") { return "$" + Number(price).toFixed(2); } return "$" + price.toFixed(2);}console.log(formatPrice(3));console.log(formatPrice("4.5"));Inside typeof price === "string", Number(price) turns the text into a number, so toFixed works. After the if, price is a number. formatPrice("4.5") prints $4.50.
TypeScript has no complaints. What does the starter print for discountText(0)? Then change line 2 so a 0% discount prints 0% off.
function discountText(percent: number | undefined) { if (percent) { return `${percent}% off`; } return "No discount set";}console.log(discountText(20));console.log(discountText(0));function discountText(percent: number | undefined) { if (percent !== undefined) { return `${percent}% off`; } return "No discount set";}console.log(discountText(20));console.log(discountText(0));The starter prints No discount set for 0, because 0 is falsy. With percent !== undefined, 0 stays in the if, and the code prints 0% off. percent is still a number there.
Line 7 gets two errors. Fix the function so they go away, and so a missing order gives back a message. What does orderSummary(undefined) print then?
type Order = { id: number; items: string[] }; function orderSummary(order: Order | undefined) { if (order === undefined) { console.log("No order yet"); } return `Order ${order.id}: ${order.items.length} items`;}console.log(orderSummary({ id: 7, items: ["tea", "cake"] }));type Order = { id: number; items: string[] }; function orderSummary(order: Order | undefined) { if (order === undefined) { return "No order yet"; } return `Order ${order.id}: ${order.items.length} items`;}console.log(orderSummary({ id: 7, items: ["tea", "cake"] }));console.log(orderSummary(undefined));With return "No order yet" on line 5, an undefined order never reaches line 7, so order is an Order there and both errors go. orderSummary(undefined) prints No order yet.
Which value can still get past nickname !== null on line 2? Then change line 2 so the error goes away.
function displayName(nickname: string | null | undefined, name: string) { if (nickname !== null) { return nickname.trim(); } return name;}console.log(displayName(" Ash ", "Asha"));console.log(displayName(null, "Asha"));function displayName(nickname: string | null | undefined, name: string) { if (nickname != null) { return nickname.trim(); } return name;}console.log(displayName(" Ash ", "Asha"));console.log(displayName(null, "Asha"));console.log(displayName(undefined, "Asha"));!== null leaves undefined, so TypeScript says 'nickname' is possibly 'undefined'. nickname != null rules out both, and all three calls print a name: Ash, Asha, Asha.
Check your understanding
For each question, follow the code from top to bottom and ask which types can still reach the line. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What does narrowing mean in TypeScript?
Choose an answer to see the explanation.
Question 2 of 7What type does
sizehave on line 3?Read the code, then predictfunction sizeLabel(size: string | number) { if (typeof size === "number") { return `${size} cm`; } return size; }Choose an answer to see the explanation.
Question 3 of 7Does TypeScript accept
tags.lengthon line 3?Read the code, then predictfunction tagCount(tags: string[] | null) { if (typeof tags === "object") { return tags.length; } return 0; }Choose an answer to see the explanation.
Question 4 of 7What does
stockText(0)print?Read the code, then predictfunction stockText(stock: number | undefined) { if (stock) { return `${stock} left`; } return "Unknown"; } console.log(stockText(0));Choose an answer to see the explanation.
Question 5 of 7Which check rules out both
nullandundefined?Choose an answer to see the explanation.
Question 6 of 7What type does
userhave on line 7?Read the code, then predicttype User = { name: string }; function welcome(user: User | undefined) { if (!user) { return "Welcome, guest"; } return `Welcome back, ${user.name}`; }Choose an answer to see the explanation.
Question 7 of 7What type does
savedhave on line 3, aftersaved === picked?Read the code, then predictfunction sameSize(saved: string | number, picked: string | boolean) { if (saved === picked) { return saved; } return "different"; }Choose an answer to see the explanation.
Key takeaways
- A union like
string | numberonly allows what works for every type in it. - Narrowing is TypeScript reading your checks to work out a smaller type in each branch. You write no new types.
typeofchecks narrow to one kind of value, but"object"still letsnullthrough.- A truthiness check removes every falsy value, including
0and"". === nulland!== undefinedrule out one value.!= nullrules out bothnullandundefined.- After an
ifthat returns or throws, the rest of the function keeps the narrower type.
Remember the one-liner.
Write the check JavaScript needs, and TypeScript follows it to the exact type.
Coming next: in & instanceof narrowing. It tells object types apart by a property they have or the class that made them.