Error Handling
TypeScript can't stop a network from failing or a user from typing nonsense, but it can make sure you handle those failures deliberately. This lesson covers typing caught errors, custom error classes, returning errors as values, and validating untrusted data at runtime.
Caught errors are unknown
In JavaScript you can throw anything — a string, a number, an object. So in strict mode, the variable in a catch clause has type unknown:
try { JSON.parse("{ broken"); } catch (err) { console.log(err.message); }
error TS18046: 'err' is of type 'unknown'.
Narrow it before use. Checking instanceof Error covers nearly every real case:
function errorMessage(err: unknown): string { if (err instanceof Error) return err.message; if (typeof err === "string") return err; return "Unknown error"; } try { JSON.parse("{ broken"); } catch (err) { console.log("Parse failed:", errorMessage(err).split(" ").slice(0, 3).join(" ")); }
Parse failed: Expected property name
A small helper like errorMessage is worth adding to any project — you'll use it in every catch block.
Custom error classes
Subclassing Error lets you attach extra data and distinguish error kinds with instanceof:
class HttpError extends Error { constructor(public readonly status: number, message: string) { super(message); this.name = "HttpError"; } } class ValidationError extends Error { constructor(public readonly field: string, message: string) { super(message); this.name = "ValidationError"; } } function handle(err: unknown) { if (err instanceof HttpError && err.status === 404) return "Not found"; if (err instanceof ValidationError) return `Fix the ${err.field} field: ${err.message}`; throw err; } console.log(handle(new HttpError(404, "missing"))); console.log(handle(new ValidationError("email", "must contain @")));
Not found Fix the email field: must contain @
Rethrowing errors you don't recognise (throw err) is important — swallowing them hides bugs. Modern JavaScript also supports new Error("msg", { cause: original }) to wrap a low-level error in a higher-level one without losing the original.
Errors as values: the Result pattern
A function's type says what it returns, but not what it might throw. When failure is an expected outcome (invalid input, item not found), returning it as a value makes it impossible for callers to forget:
type Result<T, E = string> = | { ok: true; value: T } | { ok: false; error: E }; function parseAge(input: string): Result<number> { const n = Number(input); if (!Number.isInteger(n)) return { ok: false, error: `"${input}" is not a whole number` }; if (n < 0 || n > 150) return { ok: false, error: `${n} is out of range` }; return { ok: true, value: n }; } for (const input of ["42", "abc", "200"]) { const r = parseAge(input); console.log(r.ok ? `age = ${r.value}` : `error: ${r.error}`); }
age = 42 error: "abc" is not a whole number error: 200 is out of range
Callers can't reach r.value without first checking r.ok. A good rule of thumb: throw for bugs and truly exceptional situations, return a result for failures the caller is expected to handle.
Validating untrusted data
Types vanish at runtime, so data from outside your program (API responses, form input, files, localStorage) must be checked before you trust its type. A hand-written type guard works for small shapes:
interface Settings { theme: "light" | "dark"; fontSize: number; } function isSettings(v: unknown): v is Settings { if (typeof v !== "object" || v === null) return false; const o = v as Record<string, unknown>; return (o.theme === "light" || o.theme === "dark") && typeof o.fontSize === "number"; } function loadSettings(json: string): Settings { const data: unknown = JSON.parse(json); if (!isSettings(data)) throw new Error("Invalid settings file"); return data; } console.log(loadSettings('{"theme":"dark","fontSize":16}')); try { loadSettings('{"theme":"blue"}'); } catch (e) { console.log((e as Error).message); }
{ theme: 'dark', fontSize: 16 }
Invalid settings fileFor larger shapes, a schema library is far less work. With Zod, for example, you declare the schema once and derive the TypeScript type from it, so the runtime check and the type can never disagree:
import { z } from "zod"; const SettingsSchema = z.object({ theme: z.enum(["light", "dark"]), fontSize: z.number().int().min(8), }); type Settings = z.infer<typeof SettingsSchema>; const settings: Settings = SettingsSchema.parse(JSON.parse(raw));
Valibot, ArkType and TypeBox follow the same idea.
Exhaustive error handling
Combining a discriminated union of error kinds with a never check (lesson 23) makes the compiler confirm that every kind is handled:
type LoginError = | { kind: "wrong-password" } | { kind: "locked"; until: string } | { kind: "network" }; function explain(e: LoginError): string { switch (e.kind) { case "wrong-password": return "Check your password."; case "locked": return `Account locked until ${e.until}.`; case "network": return "You appear to be offline."; default: { const unreachable: never = e; return unreachable; } } } console.log(explain({ kind: "locked", until: "10:30" }));
Account locked until 10:30.