Type predicates & assertion functions
Write your own checks that narrow types: isUser(value) with value is Type, array filters that keep the type, and asserts functions that check or throw.
- 01Write a type predicateGive a checking function the return type
value is Type, so anifthat calls it narrows the value. - 02Filter arrays and keep the typeUse a predicate with
filterto turn(Product | undefined)[]intoProduct[]. - 03Write an assertion functionUse
asserts value is Typeto check or throw, and know that TypeScript trusts a guard even when it lies.
A check that stops narrowing in a function
A shop's cart can hold two kinds of item: products, like tea, and gift cards. A product has a name and a price. A gift card has a code and a balance. To show a label for any item, you check which kind it is first. The in & instanceof lesson did this with in, which asks whether an object has a property:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function itemLabel(item: Product | GiftCard) { if ("price" in item) { return item.name; // ^? } return item.code;} console.log(itemLabel({ name: "Tea", price: 3 }));console.log(itemLabel({ code: "GIFT-20", balance: 20 }));Lines 1 and 2 describe the two kinds of item. Line 4 takes an item that is either one. Line 5 checks for a price, which only products have, so inside the if, TypeScript narrows item to a Product. The query on line 7 shows it: item: Product. After the if returns, only GiftCard is left, so line 9 may read item.code. Lines 12 and 13 print Tea and GIFT-20.
The cart page needs this check in many places, so you move it into a function with a good name, isProduct. It gives back true or false, so you say its return type is boolean:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): boolean { return "price" in item;} function itemLabel(item: Product | GiftCard) { if (isProduct(item)) { return item.name; } return item.code;} console.log(itemLabel({ name: "Tea", price: 3 }));console.log(itemLabel({ code: "GIFT-20", balance: 20 }));Lines 4 to 6 are the check, moved into isProduct. Line 9 calls it. The code still prints Tea and GIFT-20, but TypeScript marks lines 10 and 12, on purpose: Property 'name' does not exist on type 'Product | GiftCard'. That means "this item might be a gift card, which has no name". The return type boolean only says the answer is true or false. It doesn't say what true means, so TypeScript doesn't narrow item.
Change only the return type on line 4:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): item is Product { return "price" in item;} function itemLabel(item: Product | GiftCard) { if (isProduct(item)) { return item.name; } return item.code;} console.log(itemLabel({ name: "Tea", price: 3 }));console.log(itemLabel({ code: "GIFT-20", balance: 20 }));Line 4 now says item is Product: "when this gives back true, item is a Product". Both errors are gone. On line 10, item is a Product, and on line 12 it's the other member, a GiftCard. The code prints the same two lines; only what TypeScript knows has changed.
item is Product tells TypeScript what each answer means. When the call gives back true, item is a Product. When it gives back false, it's the other member, GiftCard.At an airport, a security officer checks your suitcase once and puts a sticker on it. At the gate, the staff read the sticker and let the bag through. They trust the sticker, so they don't open the bag again.
- In real life: A security officer opens your bag and checks it
- In TypeScript: The body of
isProductchecks the item - In real life: The officer puts a sticker on it: checked, no liquids
- In TypeScript: The return type says what
truemeans:item is Product - In real life: At the gate, staff read the sticker instead of opening the bag
- In TypeScript: At the
if, TypeScript reads the return type and narrows - In real life: Nobody at the gate checks that the officer did the job
- In TypeScript: TypeScript doesn't check that the body matches the return type
Any check that narrows, like typeof, is a type guard. A user-defined type guard is a function you write whose return type says what it checks; this lesson calls it a guard for short. value is Type, a type predicate, means "when this gives back true, value is a Type". asserts value is Type means "if this returns at all, value is a Type". TypeScript trusts what the return type says.
value is Type: a check that narrows
A type predicate goes where a return type goes, after the parameter list. It has three parts:
- the name of one of the function's parameters, like
item; - the word
is; - the type that
truestands for, likeProduct.
The function still gives back true or false. The predicate only tells TypeScript what those answers mean. Try different return types on line 4 and watch what TypeScript thinks item is:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): boolean { return "price" in item;} function itemLabel(item: Product | GiftCard) { if (isProduct(item)) { return item.name; } return item.code;} console.log(itemLabel({ name: "Tea", price: 3 }));console.log(itemLabel({ code: "GIFT-20", balance: 20 }));2 errors: lines 10 and 12Product | GiftCardProduct | GiftCardTea, GIFT-20boolean only says the function gives back true or false. TypeScript doesn't know what true means, so item stays Product | GiftCard on both lines. Line 10 gets Property 'name' does not exist on type 'Product | GiftCard', and line 12 gets the same for code.
: boolean narrows nothing, so lines 10 and 12 get errors. : item is Product narrows both ways. Left out works too, because TypeScript works out the predicate from the body itself. And : item is GiftCard shows how much TypeScript trusts a predicate: it narrows the wrong way and blames lines 10 and 12, even though the body didn't change.
Here is what TypeScript works out when you leave the return type out:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard) {// ^? return "price" in item;}The query on line 5 points at isProduct on line 4. It shows function isProduct(item: Product | GiftCard): item is Product. Since TypeScript 5.5, a function with no return type and a single return of a check on its parameter gets a predicate this way. Writing item is Product yourself still helps: it documents the function, and it works for bodies TypeScript can't read this way.
Type guards matter most for data from outside, like a user saved in the browser. JSON.parse gives back any, which TypeScript never checks, so the examples here store its result as unknown: a value TypeScript knows nothing about and lets you use only after a check.
type User = { name: string; email: string }; function isUser(value: unknown): value is User { return ( typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" && "email" in value && typeof value.email === "string" );} const saved: unknown = JSON.parse('{ "name": "Asha", "email": "asha@example.com" }');if (isUser(saved)) { console.log(`Welcome back, ${saved.name}`);} else { console.log("Please sign in");}Lines 3 to 12 are the guard, isUser. Each line of the check narrows value a little further:
- Line 5 checks that it's an object. In JavaScript,
typeof nullis also"object". (The Checking types reliably lesson explains why.) So line 6 rules outnull. - Lines 7 and 8 check that it has a
name, and that the name is text. - Lines 9 and 10 check the same for
email.
Line 14 reads a saved user. Line 15 calls the guard, so inside the if, saved is a User, and line 16 may read saved.name. It prints Welcome back, Asha. Anything else, like null or an object with no email, goes to line 18.
Filtering arrays with predicates
filter makes a new array with the items its function says true for. (The map, filter, and reduce lesson covers it.) When that function is a type guard, filter uses its predicate for the new array's type:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): item is Product { return "price" in item;} const cart: (Product | GiftCard)[] = [ { name: "Tea", price: 3 }, { code: "GIFT-20", balance: 20 }, { name: "Cake", price: 4 },]; const products = cart.filter(isProduct);// ^?console.log(products.map((product) => product.name).join(", "));Line 8 makes a cart of two products and a gift card, typed (Product | GiftCard)[]. Line 14 passes isProduct to filter. The query on line 15 shows products: Product[]: an array of products only. So line 16 may read each product.name, and it prints Tea, Cake.
cart.filter(isProduct) keeps the items isProduct says true for. Because its return type is item is Product, TypeScript knows the new array holds only products: Product[].The most common use is removing missing values. find gives back undefined when nothing matches. (The Searching arrays lesson covers it.) Here the shop looks up three ids, and one of them, 5, doesn't exist. Pick a way to filter on line 9:
type Product = { id: number; name: string; price: number }; const products: Product[] = [ { id: 1, name: "Tea", price: 3 }, { id: 2, name: "Cake", price: 4 },]; const found = [1, 5, 2].map((id) => products.find((product) => product.id === id));const inCart = found.filter(Boolean);// ^?console.log(inCart.map((product) => product.name).join(", "));(Product | undefined)[]1 error: line 11Tea, CakeBoolean drops the undefined while the code runs, but its type says nothing about product. The query shows (Product | undefined)[], so line 11 gets 'product' is possibly 'undefined'.
filter(Boolean) drops the undefined when the code runs, but its type keeps it, so line 11 gets 'product' is possibly 'undefined'. The other three give Product[]: TypeScript works out the predicate from product !== undefined and !!product, and the last one writes it out. All four print Tea, Cake; only what TypeScript knows differs.
asserts value is Type: check or stop
Sometimes there's nothing sensible to do when a check fails. The cart page needs its total element, and if it's missing, the page is broken. getElementById gives back the element, or null when no element has that id:
const total = document.getElementById("cart-total");total.textContent = "$7";Line 1 looks up the element with the id cart-total. Line 2 sets its text, and TypeScript marks it on purpose: 'total' is possibly 'null'. You could wrap the rest of the page in an if. An assertion function is shorter: it checks, and if the check fails, it throws an error.
function assertIsDefined(element: HTMLElement | null): asserts element is HTMLElement { if (element === null) { throw new Error("Element not found"); }} const total = document.getElementById("cart-total");// ^?assertIsDefined(total);total.textContent = "$7";//^?console.log(total.textContent);Line 1 gives assertIsDefined the return type asserts element is HTMLElement: "if this function returns, element is an HTMLElement". Lines 2 to 4 throw when the element is null. Line 7 looks up the element, and the query on line 8 shows HTMLElement | null. Line 9 calls the assertion. From then on, the query on line 11 shows total: HTMLElement, so line 10 may set the text. Line 12 prints $7.
assertIsDefined(total) has two ways out. If total is null, it throws and the code stops. Otherwise it returns, and on every line after the call TypeScript knows total is an HTMLElement.When the element really is missing, the assertion stops the code with a clear message:
function assertIsDefined(element: HTMLElement | null): asserts element is HTMLElement { if (element === null) { throw new Error("Element not found"); }} const coupon = document.getElementById("coupon-box");try { assertIsDefined(coupon); coupon.textContent = "20% off";} catch (error) { console.log(String(error));}The page has no element with the id coupon-box, so line 7 gets null. Line 9 throws, and line 10 never runs. The try and catch on lines 8 to 13 catch the error, and line 12 prints Error: Element not found. Without the assertion, line 10 would throw a less helpful TypeError.
An assertion function can also take any condition. Then it uses asserts and the parameter's name, with no type:
function assert(condition: unknown, message: string): asserts condition { if (!condition) { throw new Error(message); }} const price: unknown = JSON.parse("3");assert(typeof price === "number", "Price must be a number");console.log(price.toFixed(2));Line 1 says asserts condition: "if this returns, condition was true". Line 7 reads a price as unknown. Line 8 asserts that it's a number. TypeScript applies that check to every line after the call, so line 9 may call price.toFixed(2), and it prints 3.00. Node's built-in assert works this way.
The table compares the two kinds of type guard. Read each row as a question, then each kind's answer.
| Question | value is Type | asserts value is Type |
|---|---|---|
| What does it give back? | true or false. | Nothing. It returns normally, or throws. |
| How do you call it? | Inside a condition: if (isProduct(item)) { … }. | On a line of its own: assertIsDefined(total);. |
| Where is the value narrower? | Inside the if, and the else gets the other members. | On every line after the call. |
| What happens when the check fails? | It gives back false, and your else runs. | It throws an error, and the code after it doesn't run. |
When a guard lies
TypeScript checks that a guard's body gives back a boolean. It doesn't check that the body tests what the predicate says. If the two disagree, TypeScript believes the predicate:
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): item is Product { return "code" in item;} function itemLabel(item: Product | GiftCard) { if (isProduct(item)) { return item.name; } return item.code;} console.log(itemLabel({ name: "Tea", price: 3 }));console.log(itemLabel({ code: "GIFT-20", balance: 20 }));Line 4 still says item is Product, but line 5 now checks for a code, which only gift cards have. There are no errors. When it runs, the tea has no code, so it goes to line 12, and item.code is undefined. The gift card has a code, so it goes to line 10, and item.name is undefined. Lines 15 and 16 print undefined twice.
"code" in item, which is true for a gift card, but the return type says item is Product. TypeScript believes the return type, so item.name has no error and gives undefined.A guard is a promise you make to TypeScript, so keep it easy to keep:
- Keep the check short, and next to the predicate it promises.
- Check every property the type needs, like
isUserchecks bothnameandemail. - Test the guard with values that should pass and values that shouldn't.
- When TypeScript can work the predicate out itself, let it: an inferred predicate can't lie.
Saved settings you can trust
A settings page saves the visitor's theme and font size as text in the browser, and reads them back on the next visit. By then the text could be anything: an old version, a typo, or nothing useful. A guard decides whether to trust it:
type Settings = { theme: "light" | "dark"; fontSize: number }; const defaults: Settings = { theme: "light", fontSize: 16 }; function isSettings(value: unknown): value is Settings { return ( typeof value === "object" && value !== null && "theme" in value && (value.theme === "light" || value.theme === "dark") && "fontSize" in value && typeof value.fontSize === "number" );} function loadSettings(saved: string): Settings { const data: unknown = JSON.parse(saved); return isSettings(data) ? data : defaults;} console.log(loadSettings('{ "theme": "dark", "fontSize": 18 }').theme);console.log(loadSettings('{ "theme": "blue" }').theme);Line 1 says a theme is "light" or "dark", and line 3 keeps safe defaults. Lines 5 to 14 are the guard: line 10 checks the theme against both allowed values, and line 12 checks that the font size is a number. Line 18 gives back the saved settings when the guard says yes, and the defaults otherwise. Line 21 prints dark. The text on line 22 has the theme "blue" and no font size, so the guard says no, and it prints light.
function isText(value: unknown): value is string { return typeof value === "string"; }function isText(value: unknown): input is string { return typeof value === "string"; }function isText(value: number): value is string { return false; }function isText(value: unknown): value is string { return "yes"; }const isText = (value: unknown): value is string => typeof value === "string";function isText(value: unknown): value is string { return typeof value === "number"; }function check(value: unknown): asserts value is string { if (typeof value !== "string") throw new Error("Not text"); }function check(value: unknown): asserts value is string { return true; }
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
An assertion function written as an arrow function needs one more step:
const assertIsText = (value: unknown): asserts value is string => { if (typeof value !== "string") { throw new Error("Not text"); }}; const search: unknown = "tea";assertIsText(search);console.log(search.toUpperCase());Lines 1 to 5 store an arrow function with asserts value is string in a const. Line 8 calls it, and TypeScript marks the call on purpose: Assertions require every name in the call target to be declared with an explicit type annotation. That means "write the type on the name assertIsText itself, not only on the arrow function". Because the assertion didn't count, line 9 gets 'search' is of type 'unknown' too. The code still prints TEA. Write it as function assertIsText(…) instead, and both errors go away.
- "A type predicate checks the value for me." No. Your code in the body checks it. TypeScript only trusts what the return type says.
- "Returning a boolean is enough." No.
: booleansays nothing about the value, so nothing narrows. Writevalue is Type, or leave the return type out so TypeScript can work it out. - "filter(Boolean) removes undefined from the type." No. It removes it from the array, but the type stays
(Product | undefined)[]. Usefilter((product) => product !== undefined). - "An assertion function returns true or false." No. It returns nothing, or it throws.
- "TypeScript makes sure my guard is right." No. A guard that checks the wrong thing has no error, and everything after it is wrong.
- "Guards are only for classes or complicated types." No. Any check you'd write in an
ifcan move into a guard, from!== undefinedto a whole saved user.
Practice exercises
Each exercise is TypeScript. When you fix or write 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 item have on line 12? Guess first. Then point at item to check, or press Edit & run and add a // ^? line under it.
type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): item is Product { return "price" in item;} function itemLabel(item: Product | GiftCard) { if (isProduct(item)) { return item.name; } return item.code;} console.log(itemLabel({ name: "Tea", price: 3 }));console.log(itemLabel({ code: "GIFT-20", balance: 20 }));type Product = { name: string; price: number };type GiftCard = { code: string; balance: number }; function isProduct(item: Product | GiftCard): item is Product { return "price" in item;} function itemLabel(item: Product | GiftCard) { if (isProduct(item)) { return item.name; } return item.code; // ^?}The predicate narrows both ways. When isProduct(item) is false, item is the other member of the union, so on line 12 it's a GiftCard. The query on line 13 shows it.
isCoupon checks for null, but TypeScript still marks line 13. Change one line so the check narrows. What does the fixed code print?
type Coupon = { code: string; percent: number }; function findCoupon(code: string): Coupon | null { return code === "TEA20" ? { code, percent: 20 } : null;} function isCoupon(value: Coupon | null): boolean { return value !== null;} const coupon = findCoupon("TEA20");if (isCoupon(coupon)) { console.log(coupon.percent);}type Coupon = { code: string; percent: number }; function findCoupon(code: string): Coupon | null { return code === "TEA20" ? { code, percent: 20 } : null;} function isCoupon(value: Coupon | null): value is Coupon { return value !== null;} const coupon = findCoupon("TEA20");if (isCoupon(coupon)) { console.log(coupon.percent);}With value is Coupon, isCoupon(coupon) narrows coupon to Coupon inside the if, so reading percent is fine. TEA20 is a real coupon, so it prints 20.
Line 9 removes the missing product, but TypeScript marks line 10. Fix line 9. What does the total come to?
type Product = { id: number; name: string; price: number }; const products: Product[] = [ { id: 1, name: "Tea", price: 3 }, { id: 2, name: "Cake", price: 4 },]; const found = [1, 5, 2].map((id) => products.find((product) => product.id === id));const inCart = found.filter(Boolean);const total = inCart.reduce((sum, product) => sum + product.price, 0);console.log(total);type Product = { id: number; name: string; price: number }; const products: Product[] = [ { id: 1, name: "Tea", price: 3 }, { id: 2, name: "Cake", price: 4 },]; const found = [1, 5, 2].map((id) => products.find((product) => product.id === id));const inCart = found.filter((product) => product !== undefined);const total = inCart.reduce((sum, product) => sum + product.price, 0);console.log(total);TypeScript works out the predicate product is Product from product !== undefined, so inCart is Product[] and line 10 may read product.price. Tea and cake cost 3 and 4, so it prints 7.
A todo list saved its first item. Write the missing isTodo guard so both errors go away. What does the code print?
type Todo = { title: string; done: boolean }; const saved: unknown = JSON.parse('{ "title": "Buy tea", "done": false }');if (isTodo(saved)) { console.log(saved.title);}type Todo = { title: string; done: boolean }; function isTodo(value: unknown): value is Todo { return ( typeof value === "object" && value !== null && "title" in value && typeof value.title === "string" && "done" in value && typeof value.done === "boolean" );} const saved: unknown = JSON.parse('{ "title": "Buy tea", "done": false }');if (isTodo(saved)) { console.log(saved.title);}isTodo checks every property Todo needs, and its predicate says so. Inside the if, saved is a Todo, so saved.title is allowed, and it prints Buy tea.
querySelector gives back the first matching element, or null. Write an assertion function so lines 2 and 3 have no errors. What does the code print?
const search = document.querySelector("input");search.value = "tea";console.log(search.value);function assertIsDefined(input: HTMLInputElement | null): asserts input is HTMLInputElement { if (input === null) { throw new Error("Search box not found"); }} const search = document.querySelector("input");assertIsDefined(search);search.value = "tea";console.log(search.value);After assertIsDefined(search), TypeScript knows search isn't null, so setting and reading its value are allowed. The page has a search box, so the assertion passes and it prints tea.
A checkout page shows the payment method. TypeScript is happy, but the label is wrong. What does the starter code print? Then fix the guard.
type Card = { last4: string };type PayPal = { email: string }; function isCard(payment: Card | PayPal): payment is Card { return "email" in payment;} function paymentLabel(payment: Card | PayPal) { if (isCard(payment)) { return `Card ending ${payment.last4}`; } return `PayPal (${payment.email})`;} console.log(paymentLabel({ last4: "4242" }));type Card = { last4: string };type PayPal = { email: string }; function isCard(payment: Card | PayPal): payment is Card { return "last4" in payment;} function paymentLabel(payment: Card | PayPal) { if (isCard(payment)) { return `Card ending ${payment.last4}`; } return `PayPal (${payment.email})`;} console.log(paymentLabel({ last4: "4242" }));Line 5 checked for email, which a card doesn't have, so the card went to line 12 and printed PayPal (undefined). Checking "last4" in payment matches the predicate, and it prints Card ending 4242.
Check your understanding
For each question, ask two things: what the guard's return type promises, and what its body really checks when the code runs. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7What does the return type
item is Producttell TypeScript?Choose an answer to see the explanation.
Question 2 of 7What type does
itemhave on line 12 of this code?Read the code, then predicttype Tea = { leaves: string }; type Cake = { slices: number }; function isTea(item: Tea | Cake): item is Tea { return "leaves" in item; } function describe(item: Tea | Cake) { if (isTea(item)) { return item.leaves; } return item.slices; }Choose an answer to see the explanation.
Question 3 of 7What type does TypeScript give
names?Read the code, then predictconst names = ["Tea", undefined, "Cake"].filter(Boolean);Choose an answer to see the explanation.
Question 4 of 7This guard checks the wrong property. What happens?
Read the code, then predicttype Tea = { leaves: string }; type Cake = { slices: number }; function isTea(item: Tea | Cake): item is Tea { return "slices" in item; } const cake: Tea | Cake = JSON.parse('{ "slices": 8 }'); if (isTea(cake)) { console.log(cake.leaves); }Choose an answer to see the explanation.
Question 5 of 7Does TypeScript accept this assertion function?
Read the code, then predictconst assertIsNumber = (value: unknown): asserts value is number => { if (typeof value !== "number") throw new Error("Not a number"); }; const quantity: unknown = 2; assertIsNumber(quantity);Choose an answer to see the explanation.
Question 6 of 7What does an assertion function do when its check fails?
Choose an answer to see the explanation.
Question 7 of 7Saved data comes back as null. What does this print?
Read the code, then predicttype User = { name: string }; function isUser(value: unknown): value is User { return typeof value === "object" && value !== null && "name" in value && typeof value.name === "string"; } const saved: unknown = JSON.parse("null"); if (isUser(saved)) { console.log(`Hi, ${saved.name}`); } else { console.log("Please sign in"); }Choose an answer to see the explanation.
Key takeaways
- A check moved into a function that returns
booleanno longer narrows where you call it. - A type predicate,
value is Type, says whattruemeans, soif (isProduct(item))narrows both ways. - Since TypeScript 5.5, a simple check with no return type gets its predicate worked out for you.
- A guard passed to
filtergives an array of the narrower type;filter(Boolean)doesn't. - An assertion function,
asserts value is Type, throws when the check fails and narrows every line after the call. - TypeScript trusts a guard's return type and never compares it with the body, so a guard that lies narrows the wrong way.
Remember the one-liner.
Put your check in the body and its meaning in the return type, and TypeScript narrows wherever you call it.
Coming next: Type assertions. They tell TypeScript a type with as, with no check at all, and show when that's fine and when it hides a bug.