Typing errors
See why catch gives you unknown, narrow errors safely, give custom error classes typed fields, and return Result types that make callers handle failure.
- 01Explain unknown in catchSay why a caught error is unknown, which flag does it, and why .catch callbacks still get any.
- 02Narrow errors safelyTurn any thrown value into a message, and read typed fields from your own error classes.
- 03Return failures as valuesWrite a Result<T, E> type so the compiler makes every caller handle the failure.
Anything can be thrown
A checkout page charges a card, and the charge can fail. In JavaScript, throw can throw any value, not only Error objects (the try, catch & finally lesson covers both). This card code throws plain text, and the catch block expects an Error. First, look at it the way TypeScript saw it before version 4.4. The example turns off the flag useUnknownInCatchVariables, which the next section explains:
function payWithCard(amount: number) { if (amount > 100) throw "Card declined"; return `Paid $${amount}`;} try { console.log(payWithCard(120));} catch (error) { // ^? console.log(`Payment failed: ${error.message}`);}Line 2 throws the text "Card declined" when the amount is over 100. Line 7 pays 120, so it throws, and line 8 catches the text as error. The answer after the ^? on line 9 shows any, the type that turns checking off. So line 10 reads error.message with no complaint, and prints Payment failed: undefined: text has no message.
Now the same code with this site's normal settings, where strict is on:
function payWithCard(amount: number) { if (amount > 100) throw "Card declined"; return `Paid $${amount}`;} try { console.log(payWithCard(120));} catch (error) { // ^? console.log(`Payment failed: ${error.message}`);}The answer on line 9 is now unknown, and line 10 gets an error, on purpose: 'error' is of type 'unknown'. In plain words: "this could be anything, so check what it is before you read from it". The code still prints Payment failed: undefined if you run it. The error is TypeScript pointing at the bug before a customer sees it.
throw can send any value: an Error, text, or a plain object. They all land in the same catch (error), so the only honest type for error is unknown.At a station's lost property office, the desk takes whatever is handed in. Nobody assumes a found item is a phone and tries to call from it. They look at it first, then decide what to do.
- In real life: People hand in anything: a phone, an umbrella, a note
- In TypeScript:
throwcan throw any value: anError, text, an object - In real life: The desk logs it as "item, not yet identified"
- In TypeScript: The
catchvariable isunknown - In real life: Staff check it's a phone before looking for its owner's number
- In TypeScript:
instanceof Errorcomes before readingmessage - In real life: Some items arrive in a labelled bag with the owner's details
- In TypeScript: Your own error classes carry typed fields, like
status
A caught error is unknown, because any value can be thrown. Check what it is, usually with instanceof Error, before you read from it. For failures callers should expect, return them as values with a Result type, so TypeScript makes callers handle them.
Why catch gives you unknown
TypeScript can't know what a try block might throw. A function's type says what it gives back, but nothing about what it throws, and any line can call code that throws text. So since version 4.4, the flag useUnknownInCatchVariables makes the catch variable unknown. strict turns it on, along with the other strict checks. (unknown is the type for "a value you haven't checked yet"; the any, unknown & never lesson covers it.)
You may write the type on a catch variable, but only unknown or any. This mistake is on purpose:
try { JSON.parse("{ broken");} catch (error: Error) { console.log(error.name);} try { JSON.parse("{ broken");} catch (error: unknown) { console.log(error instanceof SyntaxError);}Line 2 parses broken JSON, which throws a SyntaxError. Line 3 says the error is an Error, and TypeScript refuses: Catch clause variable type annotation must be 'any' or 'unknown' if specified. In plain words: "I can't promise what was thrown, so I won't let you say it". Line 9 writes unknown, which is allowed and changes nothing. Line 10 checks with instanceof. When it runs, line 4 prints SyntaxError and line 10 prints true.
Promises are different. A promise that fails is "rejected", and .catch runs a callback with the reason (the Error handling with promises lesson covers this). TypeScript's own types give that reason the type any, and strict doesn't change it:
async function payWithCard(amount: number): Promise<string> { if (amount > 100) throw "Card declined"; return `Paid $${amount}`;} payWithCard(120).catch((error) => { // ^? console.log(`Payment failed: ${error.message}`);}); payWithCard(120).catch((error: unknown) => { console.log(`Payment failed: ${String(error)}`);});Line 2 throws text again, so the promise from line 6 is rejected. The answer on line 7 shows error is any, so line 8 reads message with no complaint and prints Payment failed: undefined. Line 11 writes (error: unknown) itself, which TypeScript allows, and now it would ask for a check. Line 12 uses String, which works on any value, and prints Payment failed: Card declined. A linter rule such as typescript-eslint's use-unknown-in-catch-callback-variable asks for this on every .catch.
The table below lists every place a failure reaches your code. Read each row from left to right:
| Where the error arrives | Its type with strict on |
|---|---|
catch (error) | unknown. You check it before you use it. |
catch (error: unknown) | unknown, written out. The same as above, and clear to readers. |
catch (error: any) | any. Checking is off for this error. |
catch (error: Error) | Not allowed. TypeScript can't promise that what was thrown is an Error. |
.catch((error) => …) on a promise | any, even with strict on. Write (error: unknown) yourself. |
reason in a rejected Promise.allSettled result | any, for the same reason. |
catch (error) with useUnknownInCatchVariables: false | any, as in TypeScript before version 4.4. |
Narrowing an unknown error
To use an unknown error, narrow it: check what it is, so TypeScript knows more inside the check (the in & instanceof narrowing lesson covers instanceof). Try four ways to write line 9, and watch what TypeScript says and what prints:
function payWithCard(amount: number) { if (amount > 100) throw "Card declined"; return `Paid $${amount}`;} try { console.log(payWithCard(120));} catch (error) { const message = error.message; console.log(`Payment failed: ${message}`);}Payment failed: undefined1 error: line 9lostTypeScript stops you: 'error' is of type 'unknown'. It's right to. The card throws text, and text has no message, so this prints Payment failed: undefined.
Only two choices print the real reason. error as Error is a type assertion (the Type assertions lesson covers them): it removes the error by making a promise you can't keep, because the value is text. instanceof Error really checks, and String(error) works on any value. Most code does both, in one small function:
function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "Something went wrong";} try { throw new Error("Card declined");} catch (error) { console.log(getErrorMessage(error));}Card declinedan ErrorNo errorsAn Error passes the check on line 2, so the function gives back its message.
Line 1 takes unknown, so you can pass it any caught value without a check. Line 2 returns the message of a real Error, including every class that extends it, like TypeError. Line 3 returns text as it is. Line 4 covers everything else with a message you chose. The function always gives back a string, so the rest of the page never has to think about what was thrown.
getErrorMessage asks one question at a time. Each check narrows error to a type you can use, and anything left over still gets a message.Custom error classes
Sometimes a page needs more than a message, like the HTTP status of a failed request: 401 means "log in first", and 404 means "not found". A class that extends Error can carry it (the Throwing & custom errors lesson builds these in JavaScript, and Classes in TypeScript covers typing class fields):
class HttpError extends Error { status: number; constructor(status: number, message: string) { super(message); this.name = "HttpError"; this.status = status; }} async function loadCart(): Promise<string[]> { throw new HttpError(401, "Not logged in");} async function showCart() { try { console.log(await loadCart()); } catch (error) { if (error instanceof HttpError) { console.log(error.status === 401 ? "Please log in" : `Error ${error.status}`); // ^? } else { console.log("Could not load the cart"); } }} showCart();Line 1 makes HttpError, a kind of Error. Line 2 declares a status field that holds a number. Line 5 passes the message to Error with super. Line 6 sets name, so logs and stack traces say HttpError instead of Error. Line 7 stores the status. Line 12 is a stand-in for a server that answers 401.
Line 18 catches an unknown, and line 19 checks error instanceof HttpError. Inside that check, the answer on line 21 shows error is an HttpError, so line 20 can read error.status as a number. It prints Please log in.
An error can also carry the error that caused it. new Error(message, { cause }) wraps a low-level failure in a clearer one, and keeps the original:
async function sendOrder(): Promise<number> { throw new Error("Network down");} async function checkout() { try { return await sendOrder(); } catch (error) { throw new Error("Checkout failed", { cause: error }); }} checkout().catch((error: unknown) => { if (!(error instanceof Error)) return; console.log(error.message); const cause = error.cause; // ^? if (cause instanceof Error) console.log(`Because: ${cause.message}`);});Line 2 fails with Network down. Line 9 catches it and throws a new error with the first one as its cause. Line 13 catches the new error as unknown, and line 14 stops unless it's an Error. The answer on line 17 shows the cause is unknown too: any value can be passed as a cause. So line 18 checks it before reading its message. It prints Checkout failed, then Because: Network down.
Result types instead of throwing
A thrown error is invisible in a function's type. For failures you expect, like a customer typing "abc" as a quantity, you can return the failure as a value instead. A Result type is a union of a success and a failure, told apart by an ok field (the Discriminated unions lesson covers this pattern, and the Error handling strategies lesson compares it with throwing). This first use forgets to check, on purpose:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; function parseQuantity(text: string): Result<number, string> { const quantity = Number(text); if (Number.isInteger(quantity) && quantity > 0) return { ok: true, value: quantity }; return { ok: false, error: `"${text}" is not a quantity` };} const result = parseQuantity("abc");console.log(`Total: $${result.value * 4}`);Line 1 defines Result once, with two type parameters: T for the value and E for the error (the Generic types & interfaces lesson covers them). Line 3 says parseQuantity gives back a number or a text error. Line 5 returns a success for a whole number above zero, and line 6 returns a failure for anything else. Line 10 reads result.value without checking, and TypeScript says Property 'value' does not exist on type 'Result<number, string>': a failure has no value. Run it, and it prints Total: $NaN.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; function parseQuantity(text: string): Result<number, string> { const quantity = Number(text); if (Number.isInteger(quantity) && quantity > 0) return { ok: true, value: quantity }; return { ok: false, error: `"${text}" is not a quantity` };} const result = parseQuantity("abc");if (result.ok) { console.log(`Total: $${result.value * 4}`);} else { console.log(result.error); // ^?}Line 10 checks result.ok. Inside, result is the success, so line 11 can use value. In the else, it's the failure, and the answer on line 14 shows error is a string. It prints "abc" is not a quantity. With the text "3", it would print Total: $12.
Result names both ways, and TypeScript makes callers check ok before they use value.The error type can be a union of codes, one for each failure the caller should handle (the Literal types lesson covers these), and it works the same in async code:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };type Cart = { items: number; total: number };type CartError = "not-logged-in" | "server-down"; async function loadCart(loggedIn: boolean): Promise<Result<Cart, CartError>> { if (!loggedIn) return { ok: false, error: "not-logged-in" }; return { ok: true, value: { items: 3, total: 12 } };} function errorText(error: CartError): string { switch (error) { case "not-logged-in": return "Please log in"; case "server-down": return "Try again in a minute"; }} async function showCart(loggedIn: boolean) { const result = await loadCart(loggedIn); console.log(result.ok ? `Total: $${result.value.total}` : errorText(result.error));} showCart(true).then(() => showCart(false));Line 3 lists the two ways loading a cart can fail. Line 5 gives back a Promise of a Result, so the failure is in the type even across await (the Promises & async functions lesson covers Promise<T>). Lines 10 to 17 turn each code into a message with a switch. It has a case for every code, so TypeScript knows every path returns. Line 21 prints the total or the message: Total: $12 for a logged-in visitor, then Please log in. Add a third code to line 3, and TypeScript marks line 10 until errorText handles it (the Exhaustiveness checking lesson covers this); exercise 5 tries it.
The table below compares the two ways to fail. Read each row from left to right:
| Question | Throwing an error | Returning a Result |
|---|---|---|
| Does the function's type say it can fail? | No. Its return type only names the value it gives back on success. | Yes. Result<number, string> names the failure too. |
| Does TypeScript make callers handle failure? | No. Leaving out try and catch is allowed. | Yes. Callers can't read value until they check ok. |
| What does a caller get on failure? | Whatever was thrown, typed unknown. | An error with the type you chose, like string or a union of codes. |
| What is it good for? | Bugs, and failures nothing nearby can fix, which should stop the work. | Failures callers should expect, like bad input or a declined card. |
Handling a failed checkout
A real checkout uses both. The request code throws, as most libraries do. The page code catches once, turns each failure into a message, and returns a Result that the page has to check:
class HttpError extends Error { status: number; constructor(status: number, message: string) { super(message); this.name = "HttpError"; this.status = status; }} type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; async function sendOrder(total: number): Promise<number> { if (total > 100) throw new HttpError(402, "Payment required"); if (total < 0) throw "Bad total"; return 7;} async function placeOrder(total: number): Promise<Result<number, string>> { try { return { ok: true, value: await sendOrder(total) }; } catch (error) { if (error instanceof HttpError && error.status === 402) { return { ok: false, error: "Your card was declined" }; } return { ok: false, error: "Something went wrong" }; }} async function showOrders() { for (const total of [12, 120, -1]) { const result = await placeOrder(total); console.log(result.ok ? `Order ${result.value} placed` : result.error); }} showOrders();Lines 1 to 9 are HttpError from before, and line 11 is Result. Lines 13 to 17 are a stand-in for the server: over 100 throws an HttpError with status 402, a total below zero throws plain text, and anything else gives back order number 7. Line 19 says placeOrder gives back a promise of a Result, never a thrown error. Line 21 tries the order. Line 23 turns a 402 into a clear message, and line 26 covers everything else, including the text.
Line 33 has to check result.ok before reading value. The code prints Order 7 placed, Your card was declined, and Something went wrong. Now sort some one-line snippets by what TypeScript says about them:
try {} catch (error) { console.log(error.message); }try {} catch (error: Error) {}try {} catch (error: unknown) { console.log(error.message); }try {} catch (error: any) { console.log(error.message); }try {} catch (error) { if (error instanceof Error) console.log(error.message); }Promise.reject("Down").catch((error) => console.log(error.message));const cause = new Error("Failed", { cause: 404 }).cause; console.log(cause.toFixed());try {} catch (error) { if (typeof error === "string") console.log(error.toUpperCase()); }
Sort each line by what TypeScript says about it. Each line is checked on its own, with strict on.
Common mix-ups
instanceof checks run in order, and TypeScript doesn't tell you when one hides another. This code has no type errors, and still takes the wrong branch:
class HttpError extends Error { status = 404;} function report(error: unknown) { if (error instanceof Error) { console.log(`Something went wrong: ${error.message}`); } else if (error instanceof HttpError) { console.log(`Status ${error.status}`); }} report(new HttpError("Not found"));Line 1 makes HttpError with a status of 404. Line 6 checks for any Error first, and an HttpError is an Error, so line 7 prints Something went wrong: Not found. Line 9 can never run. Check the most specific class first, as in & instanceof narrowing showed.
- "A caught error is always an Error." No. Any value can be thrown: text, objects, even
undefined. That's why it'sunknown. - "catch (error: Error) tells TypeScript what was thrown." No. TypeScript only allows
anyorunknownthere. Narrow inside the block. - "error as Error is the same as checking." No.
aschecks nothing. Text thrown as an error still has nomessage. - "strict makes .catch callbacks unknown too." No. A
.catchcallback's value is stillany. Write(error: unknown)yourself. - "A function's type says what it can throw." No. TypeScript has no type for thrown errors. Use a
Resultwhen callers must handle a failure. - "Results replace throwing everywhere." No. Throw for bugs and failures nothing nearby can fix; return a
Resultfor failures callers should expect.
Practice exercises
Each exercise is TypeScript. Press Edit & run on the starter code to try a fix: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
What type does error get? Guess first. Then point at error to check, or press Edit & run and add a // ^? line under it.
try { JSON.parse("{ broken");} catch (error) { console.log("Could not read the saved cart");}try { JSON.parse("{ broken");} catch (error) { // ^? console.log("Could not read the saved cart");}With strict on, a catch variable is unknown, because any value can be thrown. The query on line 4 shows var error: unknown.
This code means well. Which line will TypeScript mark?
function saveCart(items: number) {
if (items > 50) throw new RangeError("Too many items");
}
try {
saveCart(60);
} catch (error: RangeError) {
console.log(error.message);
}function saveCart(items: number) { if (items > 50) throw new RangeError("Too many items");} try { saveCart(60);} catch (error) { if (error instanceof RangeError) console.log(error.message);}Line 7 types the catch variable as RangeError, which TypeScript doesn't allow. Remove the type and check with instanceof RangeError inside. It prints Too many items.
TypeScript marks line 9, and the code prints Coupon failed: undefined. Fix line 9 without as. What does your fixed code print?
function applyCoupon(code: string) { if (code !== "TEA10") throw `Unknown coupon ${code}`; return 10;} try { applyCoupon("CAKE5");} catch (error) { console.log(`Coupon failed: ${error.message}`);}function applyCoupon(code: string) { if (code !== "TEA10") throw `Unknown coupon ${code}`; return 10;} try { applyCoupon("CAKE5");} catch (error) { const message = error instanceof Error ? error.message : String(error); console.log(`Coupon failed: ${message}`);}The thrown text isn't an Error, so String(error) gives it back as it is. It prints Coupon failed: Unknown coupon CAKE5 instead of undefined.
Make HttpError carry a status, and throw one with status 404. What does your fixed code print?
class HttpError extends Error { constructor(message: string) { super(message); this.name = "HttpError"; }} try { throw new HttpError("Not found");} catch (error) { if (error instanceof HttpError) console.log(`Status ${error.status}`);}class HttpError extends Error { status: number; constructor(status: number, message: string) { super(message); this.name = "HttpError"; this.status = status; }} try { throw new HttpError(404, "Not found");} catch (error) { if (error instanceof HttpError) console.log(`Status ${error.status}`);}With a typed status field set in the constructor, instanceof HttpError gives TypeScript a class that has status, and it prints Status 404.
Someone added "out-of-stock" to CartError, and TypeScript marks line 3. Fix errorText. What does your fixed code print?
type CartError = "not-logged-in" | "server-down" | "out-of-stock"; function errorText(error: CartError): string { switch (error) { case "not-logged-in": return "Please log in"; case "server-down": return "Try again in a minute"; }} console.log(errorText("out-of-stock"));type CartError = "not-logged-in" | "server-down" | "out-of-stock"; function errorText(error: CartError): string { switch (error) { case "not-logged-in": return "Please log in"; case "server-down": return "Try again in a minute"; case "out-of-stock": return "Sold out"; }} console.log(errorText("out-of-stock"));With a case for every code, every path returns a string, so the error on line 3 is gone. It prints Sold out instead of undefined.
The code runs fine today, but TypeScript marks line 14. Make it safe for any cause. What does your fixed code print?
async function sendOrder(): Promise<number> { throw new Error("Network down");} async function checkout() { try { return await sendOrder(); } catch (error) { throw new Error("Checkout failed", { cause: error }); }} checkout().catch((error: unknown) => { if (error instanceof Error) console.log(`${error.message}, because: ${error.cause.message}`);});async function sendOrder(): Promise<number> { throw new Error("Network down");} async function checkout() { try { return await sendOrder(); } catch (error) { throw new Error("Checkout failed", { cause: error }); }} checkout().catch((error: unknown) => { if (error instanceof Error && error.cause instanceof Error) { console.log(`${error.message}, because: ${error.cause.message}`); }});Checking error.cause instanceof Error narrows the cause, so its message is allowed. It prints Checkout failed, because: Network down, and a cause of any other kind would print nothing instead of crashing.
Check your understanding
For each question, ask: what could have been thrown, and what does TypeScript know about it? The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7With
stricton, what type doeserrorget incatch (error)?Choose an answer to see the explanation.
Question 2 of 7What does TypeScript say about line 7?
Read the code, then predictfunction checkCoupon(code: string) { if (code !== "TEA10") throw new Error("Unknown coupon"); } try { checkCoupon("CAKE5"); } catch (error: Error) { console.log(error.message); }Choose an answer to see the explanation.
Question 3 of 7What type does
errorget in this.catchcallback?Read the code, then predictasync function loadCart(): Promise<string[]> { throw new Error("Server down"); } loadCart().catch((error) => console.log(error.message));Choose an answer to see the explanation.
Question 4 of 7What does this code print when a plain object is thrown?
Read the code, then predictfunction getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return "Something went wrong"; } try { throw { code: 402 }; } catch (error) { console.log(getErrorMessage(error)); }Choose an answer to see the explanation.
Question 5 of 7What does
reportprint, and does TypeScript warn about the order of the checks?Read the code, then predictclass HttpError extends Error { status = 404; } function report(error: unknown) { if (error instanceof Error) { console.log(`Something went wrong: ${error.message}`); } else if (error instanceof HttpError) { console.log(`Status ${error.status}`); } } report(new HttpError("Not found"));Choose an answer to see the explanation.
Question 6 of 7Why does TypeScript mark line 10?
Read the code, then predicttype Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; function parseQuantity(text: string): Result<number, string> { const quantity = Number(text); if (Number.isInteger(quantity) && quantity > 0) return { ok: true, value: quantity }; return { ok: false, error: `"${text}" is not a quantity` }; } const result = parseQuantity("abc"); console.log(`Total: $${result.value * 4}`);Choose an answer to see the explanation.
Question 7 of 7When is returning a Result a better fit than throwing?
Choose an answer to see the explanation.
Key takeaways
- Any value can be thrown, so with
stricton, acatchvariable isunknown. - A catch variable can only be typed
anyorunknown, and a.catchcallback's value isanyunless you writeunknown. - Narrow with
instanceof Error, and fall back toString(error)or a message you chose. Never useas Erroras a check. - Custom error classes carry typed fields like
status. Check the most specific class first. - Return a
Result<T, E>for failures callers should expect, so TypeScript makes them checkok.
Remember the one-liner.
A caught error is unknown until you check it; throw for the unexpected, and return a Result for the expected.
Coming next: Typing React components. It types the components that show these messages: props, children, and event handlers.