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:

TS catch.ts
try {
  JSON.parse("{ broken");
} catch (err) {
  console.log(err.message);
}
tsc output
error TS18046: 'err' is of type 'unknown'.

Narrow it before use. Checking instanceof Error covers nearly every real case:

TS catch-narrow.ts
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(" "));
}
Output
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:

TS custom.ts
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 @")));
Output
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:

TS result.ts
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}`);
}
Output
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:

TS validate.ts
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);
}
Output
{ theme: 'dark', fontSize: 16 }
Invalid settings file

For 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:

TS zod.ts
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:

TS exhaustive.ts
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" }));
Output
Account locked until 10:30.

Try it yourself

Try it yourself
Console output