Exhaustiveness checking
Make TypeScript tell you when a switch misses a case: never in the default branch, an assertNever helper, and return types, with switches you can break.
- 01Use never to find a missing casePut the leftover value where only
neverfits, so a case you forgot becomes an error. - 02Write and use assertNeverShare one helper that TypeScript checks while you type and that throws a clear error while the page runs.
- 03Add a case safelyAdd a member to a union and follow TypeScript's errors to every switch that needs it, or use a return type to catch it.
A new order status that one switch forgot
A shop shows each order with a short label, like "On its way". A function picks the label with a switch. Then the team adds a new status, "cancelled", and nobody remembers this function:
function statusLabel(status) {
switch (status) {
case "placed":
return "We got your order";
case "shipped":
return "On its way";
case "delivered":
return "At your door";
}
}
console.log(statusLabel("cancelled"));Lines 3 to 8 handle three statuses. Line 12 asks for the label of a cancelled order. No case matches, so the function reaches its end without a return and gives back undefined. The page prints undefined where the label should be, and nothing warns you.
In TypeScript you can ask the compiler to check that a switch handles every status. This is called exhaustiveness checking: "exhaustive" means "nothing left out". Here is the same function with the check added:
type OrderStatus = "placed" | "shipped" | "delivered" | "cancelled"; function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: const unhandled: never = status; throw new Error(`Unknown status: ${unhandled}`); }} console.log(statusLabel("cancelled"));Line 1 is a union type: OrderStatus can be exactly one of four texts, and | means "or". Line 3 says status is an OrderStatus. Lines 11 to 13 are new. Line 12 puts status into a variable whose type is never. never is the type with no values: nothing is allowed to go in it.
Each case takes one status away. Whatever no case handled reaches default, and here that's "cancelled". TypeScript marks line 12, on purpose here, because it's the forgotten case:
Type '"cancelled"' is not assignable to type 'never'means "the value"cancelled"can still get here, and nothing is allowed here". The message names the case you forgot.- If you run the code anyway, line 13 throws
Error: Unknown status: cancelledinstead of showingundefined.
case takes one status. "cancelled" has no case, so it reaches default, where only never may arrive. That's the error on line 12.In a shared flat, the post is sorted into pigeonholes, one per flatmate. At the end there's a tray marked "nothing should be here". When a new flatmate moves in and their first letter lands in that tray, you notice straight away and add a pigeonhole with their name.
- In real life: Each pigeonhole has one flatmate's name on it
- In TypeScript: Each
casehandles one status - In real life: A letter for a new flatmate has no pigeonhole yet
- In TypeScript:
"cancelled"has no case yet - In real life: It lands in the tray marked "nothing should be here"
- In TypeScript: It reaches
default, where onlynevermay arrive - In real life: You spot it at once and add a pigeonhole
- In TypeScript: TypeScript marks line 12, and you add the case
Exhaustiveness checking makes TypeScript check that your code handles every member of a union. Each case narrows the value. After the last case, only never should be left. Put the value where only never fits, and any case you forgot becomes an error.
never in the default branch
never is the type with no values. No text, number, or object fits in it, so storing anything in a never variable is an error:
const unhandled: never = "placed";Line 1 tries to store "placed" in unhandled, and TypeScript says Type '"placed"' is not assignable to type 'never'. That's the error on purpose. So when is never useful? When a value has been checked so many times that no possibility is left.
TypeScript follows your checks. Inside case "placed":, it knows status is "placed", and after that case it knows status is something else. This is narrowing: the type gets smaller as your code checks it. When every case is handled, nothing is left:
type OrderStatus = "placed" | "shipped" | "delivered"; function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: const unhandled: never = status; // ^? throw new Error(`Unknown status: ${unhandled}`); }} console.log(statusLabel("shipped"));Line 1 lists three statuses, and lines 5 to 10 handle all three. The query on line 13 points at status on line 12 and shows (parameter) status: never ("parameter" because status is the function's parameter). Nothing can reach default, so storing status in a never is fine, and there are no errors. Line 18 prints On its way.
unhandled is a box of type never, which no value fits in. With a case for every status, nothing reaches it. Leave out "shipped", and that value arrives at a box it can't go in.Now take cases away and watch what reaches default. Each button adds or removes one case:
type OrderStatus = "placed" | "shipped" | "delivered"; function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: const unhandled: never = status; // ^? throw new Error(`Unknown status: ${unhandled}`); }}never3 of 3No errorsAll three statuses have a case, so nothing is left for default. There, status is never, and const unhandled: never = status is fine. No errors.
Turn off case "shipped". The code gets two lines shorter, so the never line moves up to line 10, and the query under it shows "shipped": the one status no case handles. TypeScript marks line 10. Turn off a second case and the message lists both leftovers, like '"shipped" | "delivered"'. Turn off all three and the whole OrderStatus type reaches default. Turn them back on and the error goes away.
assertNever: one helper for every switch
Writing const unhandled: never = status; in every switch gets repetitive. Most projects write a small helper once, called assertNever, and call it from every default:
type OrderStatus = "placed" | "shipped" | "delivered" | "cancelled"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: return assertNever(status); }}Lines 3 to 5 are the helper. Its parameter value has the type never, so the only thing you may pass it is a value with nothing left. Its return type is never too, which means "this function never returns": line 4 always throws an error. Line 16 calls it from default.
Line 1 lists "cancelled", but no case handles it, so it reaches line 16. TypeScript marks that line, on purpose: Argument of type '"cancelled"' is not assignable to parameter of type 'never'. An argument is a value you pass to a function, so this means "you're passing "cancelled" to a function that accepts nothing". Add the case and the error goes away.
The helper works the same for a union of objects with a shared kind field, like the request states of a page that loads data:
type RequestState = | { kind: "loading" } | { kind: "success"; items: string[] } | { kind: "error"; message: string }; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function render(state: RequestState) { switch (state.kind) { case "loading": return "Loading..."; case "success": return `${state.items.length} items`; default: return assertNever(state); }}Lines 1 to 4 describe three states: loading, success with a list of items, and error with a message. Line 11 switches on state.kind, and each case narrows the whole state object. There's no case for "error", so line 17 gets an error on purpose. This time the message names the whole leftover object type: Argument of type '{ kind: "error"; message: string; }' is not assignable to parameter of type 'never'.
assertNever also helps while the page runs. Types are removed before the code runs, so data from outside, like a server reply read with JSON.parse, can hold a value your type doesn't list:
type OrderStatus = "placed" | "shipped" | "delivered"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: return assertNever(status); }} const order: { status: OrderStatus } = JSON.parse('{ "status": "returned" }');try { console.log(statusLabel(order.status));} catch (error) { console.log(String(error));}Every status has a case, so TypeScript reports nothing. Line 20 reads an order from a server reply and says its status is an OrderStatus. TypeScript trusts that, because JSON.parse gives back any, which it never checks. But the reply says "returned". No case matches, so line 16 calls assertNever, which throws. The try and catch on lines 21 to 25 catch the error, and line 24 prints Error: Unhandled value: "returned". A clear error is much easier to find than a label that quietly says undefined.
assertNever checks twice. While you type, TypeScript marks a status with no case. While the page runs, a value your type doesn't list, like "returned" from a server, makes it throw a clear error.Adding a case safely
A real app has many switches over the same type: one picks the label, another the color, another the icon. When you add a status, the hard part is finding them all. With a check in each default, TypeScript finds them for you. Step through adding "cancelled":
type OrderStatus = "placed" | "shipped" | "delivered"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: return assertNever(status); }} function statusColor(status: OrderStatus) { switch (status) { case "placed": return "blue"; case "shipped": return "orange"; case "delivered": return "green"; default: return assertNever(status); }}No errorsNothingThree statuses, and both switches handle all three. Each default hands assertNever a never, so TypeScript has nothing to report.
Step 1 has no errors. Step 2 changes only line 1, and TypeScript marks line 12 in statusLabel and line 21 in statusColor: a to-do list of every switch that needs the new case. Step 3 adds the case to statusLabel on line 12, and one error is left, in statusColor. Step 4 adds it there too, and the errors are gone. Without the checks, step 2 would have no errors at all, and both functions would quietly give back undefined for a cancelled order.
"cancelled" to the type, and every switch that doesn't handle it gets an error: line 12 in statusLabel and line 21 in statusColor. The errors are your to-do list.Exhaustive return types
A switch that returns a value can be checked without any default at all. First, see what TypeScript works out when a case is missing and the function has no return type written:
type Shipping = "standard" | "express" | "pickup"; function deliveryDays(method: Shipping) { switch (method) { case "standard": return 5; case "express": return 1; }} let days = deliveryDays("pickup");// ^?console.log(days);Line 1 lists three ways to ship. Lines 5 to 8 handle two of them, and nothing handles "pickup". Line 12 asks for the pickup days, and the query on line 13 shows let days: number | undefined: TypeScript saw that the function can end without a return, so it can give back undefined. There's no error, and line 14 prints undefined.
Now write the return type you mean, : number, after the parameters. This is called a return type annotation:
type Shipping = "standard" | "express" | "pickup"; function deliveryDays(method: Shipping): number { switch (method) { case "standard": return 5; case "express": return 1; }}Line 3 promises a number. With no case for "pickup", the function can still reach its end without returning, so TypeScript marks line 3, on purpose: Function lacks ending return statement and return type does not include 'undefined'. In plain words: "some path through this function returns nothing, and number doesn't allow that". Add case "pickup": return 0; and the error goes away. Exercise 6 asks you to do it.
The table below compares the ways to end a switch. Read each row as: this ending, what TypeScript says when a status has no case, and what happens if a value the type doesn't list arrives while the page runs.
| How the switch ends | When a status has no case | When an unlisted value arrives while the page runs |
|---|---|---|
default: with const unhandled: never = status; | An error on that line: Type '"cancelled"' is not assignable to type 'never'. | The next line throws an error, if you write one. |
default: return assertNever(status); | An error on that line: Argument of type '"cancelled"' is not assignable to parameter of type 'never'. | assertNever throws an error that names the value. |
No default, and a return type like : number | An error on the return type: Function lacks ending return statement. | The function gives back undefined, and nothing warns you. |
default: return "Unknown status"; | No error. The fallback answers for every case you forgot. | The page shows the fallback text. |
Every payment method at checkout
A checkout page shows a short summary of how the shopper is paying. Each payment method carries different details, so the payment is a union of objects with a shared method field:
type Payment = | { method: "card"; last4: string } | { method: "paypal"; email: string } | { method: "giftCard"; balance: number }; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function paymentSummary(payment: Payment) { switch (payment.method) { case "card": return `Card ending ${payment.last4}`; case "paypal": return `PayPal (${payment.email})`; case "giftCard": return `Gift card, $${payment.balance} left`; default: return assertNever(payment); }} console.log(paymentSummary({ method: "card", last4: "4242" }));console.log(paymentSummary({ method: "giftCard", balance: 15 }));Lines 1 to 4 describe three methods: a card with its last four digits, PayPal with an email, and a gift card with a balance. Line 11 switches on payment.method. Inside each case TypeScript knows which object it has, so line 13 can read last4 and line 17 can read balance. Line 19 hands the leftover to assertNever, and since every method has a case, there's no error. Line 23 prints Card ending 4242 and line 24 prints Gift card, $15 left.
Next month the shop adds Apple Pay. Add | { method: "applePay" } to the type with Edit & run, and line 19 gets an error until you add its case.
- The switch ends with
default: return assertNever(status); - The switch ends with
default:andconst unhandled: never = status; - No
default, and the function says it returns: string - Three
ifchecks, thenreturn assertNever(status); - The switch ends with
default: return "Unknown status"; - No
default, and no return type - The switch ends with
default: throw new Error("Unknown status"); statusis a plainstring, with a fallbackdefault
The type lists "cancelled", and the statusLabel function has no case for it. Sort each way of writing the function by whether TypeScript points at the missing case.
Common mix-ups
A default branch alone doesn't make a switch safe. Here are two statuses forgotten, with no error at all:
type OrderStatus = "placed" | "shipped" | "delivered" | "cancelled"; function statusLabel(status: OrderStatus) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; default: return "Unknown status"; }} console.log(statusLabel("delivered"));Line 1 lists four statuses, and lines 5 to 8 handle only two. Line 10 returns "Unknown status" for anything else, which TypeScript accepts, because a fallback can stand for any status. Line 14 asks about a delivered order and prints Unknown status.
The check also needs a union with a fixed list of members. A plain string is never used up:
function statusLabel(status: string) { switch (status) { case "placed": return "We got your order"; case "shipped": return "On its way"; case "delivered": return "At your door"; default: const unhandled: never = status; throw new Error(`Unknown status: ${unhandled}`); }}Line 1 says status is any string. Lines 3 to 8 handle three texts, but a string can be endless other texts, so it's still a string in default. Line 10 gets Type 'string' is not assignable to type 'never' on purpose, however many cases you add. Give status a union type, like OrderStatus, to make the check work.
- "A default branch makes a switch safe." No. A
defaultthat returns a fallback answers for every forgotten case, so TypeScript has nothing to report. Put the check indefaultinstead. - "never means null or undefined." No.
undefinedis a value.neverhas no values at all: it's the type of a place your code can't reach. - "TypeScript checks every switch for missing cases on its own." No. It only reports a missing case when you ask: with a
nevervariable,assertNever, or a return type. - "The check works on any type." No. It needs a union with a fixed list of members, like
"placed" | "shipped". Astringor anumberis never used up. - "assertNever only matters while I type." No. It also throws while the page runs, when outside data has a value your type doesn't list, like
"returned".
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.
A theme switch with one theme forgotten. Which line will TypeScript mark?
type Theme = "light" | "dark" | "system";
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}
function themeIcon(theme: Theme) {
switch (theme) {
case "light":
return "sun";
case "dark":
return "moon";
default:
return assertNever(theme);
}
}type Theme = "light" | "dark" | "system"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function themeIcon(theme: Theme) { switch (theme) { case "light": return "sun"; case "dark": return "moon"; case "system": return "monitor"; default: return assertNever(theme); }} console.log(themeIcon("system"));"system" has no case, so it reaches line 14, where TypeScript says Argument of type '"system"' is not assignable to parameter of type 'never'. The fix adds case "system", and the code prints monitor.
Every theme has a case. What type does theme have in default? Guess first. Then point at theme at the end of line 12 to check.
type Theme = "light" | "dark" | "system"; function themeIcon(theme: Theme) { switch (theme) { case "light": return "sun"; case "dark": return "moon"; case "system": return "monitor"; default: const unhandled: never = theme; throw new Error(`Unknown theme: ${unhandled}`); }}type Theme = "light" | "dark" | "system"; function themeIcon(theme: Theme) { switch (theme) { case "light": return "sun"; case "dark": return "moon"; case "system": return "monitor"; default: const unhandled: never = theme; // ^? throw new Error(`Unknown theme: ${unhandled}`); }}Every theme has a case, so nothing reaches default. The query on line 13 shows (parameter) theme: never, which is why line 12 has no error.
A designer added a red "danger" button. Make buttonClass("danger") return "btn btn-red", and change the last line to print it. What does it print?
type ButtonVariant = "primary" | "secondary" | "danger"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function buttonClass(variant: ButtonVariant) { switch (variant) { case "primary": return "btn btn-blue"; case "secondary": return "btn btn-grey"; default: return assertNever(variant); }} console.log(buttonClass("primary"));type ButtonVariant = "primary" | "secondary" | "danger"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function buttonClass(variant: ButtonVariant) { switch (variant) { case "primary": return "btn btn-blue"; case "secondary": return "btn btn-grey"; case "danger": return "btn btn-red"; default: return assertNever(variant); }} console.log(buttonClass("danger"));With a case for "danger", nothing is left for default, so assertNever gets a never and the error is gone. buttonClass("danger") prints btn btn-red.
Line 12 calls assertNever, but nobody wrote it. Write the helper above sizeLabel. What return type does it have?
type Size = "S" | "M" | "L"; function sizeLabel(size: Size) { switch (size) { case "S": return "Small"; case "M": return "Medium"; case "L": return "Large"; default: return assertNever(size); }} console.log(sizeLabel("M"));type Size = "S" | "M" | "L"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function sizeLabel(size: Size) { switch (size) { case "S": return "Small"; case "M": return "Medium"; case "L": return "Large"; default: return assertNever(size); }} console.log(sizeLabel("M"));assertNever(value: never): never takes only never and always throws, so its return type is never. With the helper written, the call on line 16 is fine, because every size has a case. The code prints Medium.
A notification bell shows three kinds of notice. TypeScript reports nothing. What does the starter print? Then add a check so TypeScript finds the missing kind, and handle it.
type Notice = | { kind: "message"; from: string } | { kind: "sale"; percent: number } | { kind: "shipped"; orderId: number }; function noticeText(notice: Notice) { switch (notice.kind) { case "message": return `New message from ${notice.from}`; case "sale": return `${notice.percent}% off today`; }} console.log(noticeText({ kind: "shipped", orderId: 42 }));type Notice = | { kind: "message"; from: string } | { kind: "sale"; percent: number } | { kind: "shipped"; orderId: number }; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`);} function noticeText(notice: Notice) { switch (notice.kind) { case "message": return `New message from ${notice.from}`; case "sale": return `${notice.percent}% off today`; case "shipped": return `Order ${notice.orderId} has shipped`; default: return assertNever(notice); }} console.log(noticeText({ kind: "shipped", orderId: 42 }));The starter prints undefined, because no case matches "shipped". With default: return assertNever(notice);, TypeScript reports the missing kind until you add its case, which reads notice.orderId and prints Order 42 has shipped.
This function has no default, and its return type catches the missing method. Fix it so pickup takes 0 days. What does deliveryDays("pickup") print?
type Shipping = "standard" | "express" | "pickup"; function deliveryDays(method: Shipping): number { switch (method) { case "standard": return 5; case "express": return 1; }}type Shipping = "standard" | "express" | "pickup"; function deliveryDays(method: Shipping): number { switch (method) { case "standard": return 5; case "express": return 1; case "pickup": return 0; }} console.log(deliveryDays("pickup"));With a case for "pickup", every path returns a number, so the : number promise holds and the error is gone. deliveryDays("pickup") prints 0, because pickup needs no delivery days.
Check your understanding
For each question, ask two things: which members of the union reach default, and what the code does when it runs. The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7Every status has a case. What type does
statushave in thedefaultbranch?Choose an answer to see the explanation.
Question 2 of 7Which line does TypeScript flag in this tab switch?
Read the code, then predicttype Tab = "home" | "search" | "profile"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`); } function tabTitle(tab: Tab) { switch (tab) { case "home": return "Home"; case "profile": return "Your profile"; default: return assertNever(tab); } }Choose an answer to see the explanation.
Question 3 of 7This switch has no check in it. What does it print, and does TypeScript complain?
Read the code, then predicttype Tab = "home" | "search" | "profile"; function tabTitle(tab: Tab) { switch (tab) { case "home": return "Home"; case "profile": return "Your profile"; } } console.log(tabTitle("search"));Choose an answer to see the explanation.
Question 4 of 7What type does TypeScript name in the error on line 8?
Read the code, then predicttype Tab = "home" | "search" | "profile"; function tabTitle(tab: Tab) { switch (tab) { case "home": return "Home"; default: const unhandled: never = tab; throw new Error(`Unknown tab: ${unhandled}`); } }Choose an answer to see the explanation.
Question 5 of 7Every tab has a case, yet TypeScript flags line 10. Why?
Read the code, then predictfunction tabTitle(tab: string) { switch (tab) { case "home": return "Home"; case "search": return "Search"; case "profile": return "Your profile"; default: const unhandled: never = tab; throw new Error(`Unknown tab: ${unhandled}`); } }Choose an answer to see the explanation.
Question 6 of 7A saved tab comes back from storage as "settings". What does this print?
Read the code, then predicttype Tab = "home" | "search" | "profile"; function assertNever(value: never): never { throw new Error(`Unhandled value: ${JSON.stringify(value)}`); } function tabTitle(tab: Tab) { switch (tab) { case "home": return "Home"; case "search": return "Search"; case "profile": return "Your profile"; default: return assertNever(tab); } } const saved: Tab = JSON.parse('"settings"'); try { console.log(tabTitle(saved)); } catch (error) { console.log(String(error)); }Choose an answer to see the explanation.
Question 7 of 7Why does
assertNevertake a parameter of typenever?Choose an answer to see the explanation.
Key takeaways
- Each case narrows a union. After the last case, the value's type is
never: nothing is left. - Put the leftover where only
neverfits, and a forgotten case becomes an error that names it. assertNever(value: never): neverdoes that in one shared helper, and throws a clear error if an unlisted value arrives while the page runs.- Add a member to a union, and the errors list every switch that still needs a case.
- A return type like
: numberalso catches a missing case in a switch with nodefault. - A fallback
defaultor a plainstringtype turns the check off.
Remember the one-liner.
Handle every case, and never is all that's left; forget one, and TypeScript tells you which.
Coming next: Type predicates & assertion functions. You'll write your own checks, like isUser(value), that narrow types the way case does here.