Lesson 8 of 1015 min
Error handling
Things go wrong: networks fail, input is malformed, bugs happen. Decide which failures to throw, which to return, and validate data at the edges.
In this lesson
- Throw and catch errors, and narrow unknown errors safely
- Create custom error classes
- Choose between throwing and returning a result
- Validate untrusted data at runtime
throw, try, catch, finally#
function parseJson(text: string): unknown { try { return JSON.parse(text);1 } catch (error) { console.error("Could not parse JSON"); throw error;2 } finally {3 console.log("parse attempt finished"); }}parseJson('{"ok": true}');JSON.parse throws a SyntaxError on malformed input.
Rethrowing passes the error on after logging, so the caller still knows it failed.
finally runs whether the try succeeded or not. Use it for clean-up like closing files or hiding spinners.
Caught errors are unknown#
Anything can be thrown in JavaScript, not only Error objects. TypeScript therefore types a caught value as unknown, and you narrow it before use.
try { JSON.parse("{oops");} catch (error) { const message = error instanceof Error ? error.message : String(error);1 console.log(`Failed: ${message}`);}instanceof Error narrows to Error, which has message. The fallback handles anything else that was thrown.
Custom errors#
A subclass carries extra context and lets callers react to specific failures.
class NotFoundError extends Error { readonly resource: string; constructor(resource: string) { super(`${resource} was not found`); this.name = "NotFoundError";1 this.resource = resource; }}function findUser(id: number): string { if (id !== 1) throw new NotFoundError(`User ${id}`); return "Ayu";}try { findUser(2);} catch (error) { if (error instanceof NotFoundError) console.log(`404: ${error.resource}`);2 else throw error;}Setting name makes logs and stack traces show the right class.
Handle the errors you expect; rethrow the rest so real bugs are not swallowed.
Throw or return?#
Not every failure is exceptional. "The user typed letters into the age field" is an expected outcome, and returning it as data makes callers handle it. Reserve throw for situations the current function cannot sensibly handle.
type Result<T> = { ok: true; value: T } | { ok: false; error: string };function parseQuantity(input: string): Result<number> { const value = Number(input); if (input.trim() === "" || !Number.isInteger(value)) return { ok: false, error: "Enter a whole number" }; if (value < 1 || value > 99) return { ok: false, error: "Choose between 1 and 99" }; return { ok: true, value };}const quantity = parseQuantity("3");console.log(quantity.ok ? quantity.value : quantity.error);Throw
- Unexpected failures and bugs
- Stops the current flow; easy to forget to catch
- Good for "this should never happen"
Return a result
- Expected failures (validation, not found)
- The type forces callers to handle both cases
- Good for user input and business rules
Validate data at the edges#
Types disappear at runtime. When data comes from outside (an API, a file, a form) check its shape before trusting it. A type guard is a function whose return type tells TypeScript what a successful check proves.
type User = { id: number; name: string };function isUser(value: unknown): value is User {1 return ( typeof value === "object" && value !== null && typeof (value as Record<string, unknown>).id === "number" &&2 typeof (value as Record<string, unknown>).name === "string" );}const data: unknown = JSON.parse('{"id": 1, "name": "Ayu"}');if (isUser(data)) console.log(data.name.toUpperCase());else console.log("Unexpected data shape");value is User means: if this returns true, treat value as a User from here on.
Hand-written guards get long. Schema libraries such as Zod or Valibot generate both the check and the type from one definition.
Warning
Never show raw error messages or stack traces to users: they can leak internal details. Log the details, show a short, helpful message.
ExerciseSafe division
Write divide(a: number, b: number): Result<number> that returns an error result when b is 0 instead of returning Infinity.
Show solution
type Result<T> = { ok: true; value: T } | { ok: false; error: string };function divide(a: number, b: number): Result<number> { if (b === 0) return { ok: false, error: "Cannot divide by zero" }; return { ok: true, value: a / b };}console.log(divide(10, 2), divide(1, 0));Recap#
try/catch/finallyhandle thrown errors; caught values areunknownuntil narrowed.- Custom error classes add context; rethrow what you do not handle.
- Return results for expected failures; validate untrusted data with guards or schemas.