Skip to editor content
learningtypescript.orglesson 9 of 25

Error Handling

TypeScript enhances JavaScript's error handling with type narrowing, custom error classes, and the Result pattern that makes error states explicit and impossible to ignore.

Try/Catch with Type Narrowing

In TypeScript, caught errors are typed as unknown, forcing you to check before using them.

function parseJSON(input: string): unknown {
  try {
    return JSON.parse(input);
  } catch (error) {
    if (error instanceof SyntaxError) {
      console.log(`Syntax error: ${error.message}`);
    } else if (error instanceof Error) {
      console.log(`Error: ${error.message}`);
    } else {
      console.log(`Unknown error: ${String(error)}`);
    }
    return null;
  }
}

console.log("Valid:", parseJSON('{"name": "Alice"}'));
console.log("Invalid:", parseJSON("{ bad json }"));

// try/catch/finally
function readConfig(filename: string): string {
  try {
    if (!filename) throw new Error("Filename cannot be empty");
    return `config from ${filename}`;
  } catch (error) {
    if (error instanceof Error) console.log(`Failed: ${error.message}`);
    return "default config";
  } finally {
    console.log(`Cleanup for: ${filename || "(empty)"}`);
  }
}

console.log(`\nResult: ${readConfig("app.json")}`);
console.log(`Result: ${readConfig("")}`);

Custom Error Classes

Custom errors carry structured information about what went wrong.

class ValidationError extends Error {
  constructor(public field: string, public value: unknown, message: string) {
    super(message);
    this.name = "ValidationError";
  }
}

class NotFoundError extends Error {
  constructor(public resource: string, public id: string | number) {
    super(`${resource} with id '${id}' not found`);
    this.name = "NotFoundError";
  }
}

interface User { id: number; name: string; email: string }

const users: User[] = [
  { id: 1, name: "Alice", email: "alice@example.com" },
  { id: 2, name: "Bob", email: "bob@example.com" }
];

function findUser(id: number): User {
  if (id <= 0) throw new ValidationError("id", id, "Must be positive");
  const user = users.find(u => u.id === id);
  if (!user) throw new NotFoundError("User", id);
  return user;
}

function handleRequest(userId: number): void {
  try {
    const user = findUser(userId);
    console.log(`Found: ${user.name} (${user.email})`);
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log(`Validation: '${error.field}' - ${error.message}`);
    } else if (error instanceof NotFoundError) {
      console.log(`Not found: ${error.resource} #${error.id}`);
    }
  }
}

handleRequest(1);
handleRequest(99);
handleRequest(-5);

The Result Pattern

Instead of throwing, make success and failure explicit in the return type.

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function ok<T>(value: T): Result<T, never> {
  return { ok: true, value };
}

function err<E>(error: E): Result<never, E> {
  return { ok: false, error };
}

interface UserInput { name: string; email: string; age: number }

function validateUser(input: UserInput): Result<UserInput, string> {
  if (input.name.trim().length < 2) return err("Name must be at least 2 characters");
  if (!input.email.includes("@")) return err("Invalid email address");
  if (input.age < 0 || input.age > 150) return err("Age must be between 0 and 150");
  return ok(input);
}

function processUser(input: UserInput): void {
  const result = validateUser(input);
  if (result.ok) {
    console.log(`Valid: ${result.value.name} (${result.value.email})`);
  } else {
    console.log(`Failed: ${result.error}`);
  }
}

processUser({ name: "Alice", email: "alice@example.com", age: 30 });
processUser({ name: "", email: "bad", age: 200 });
processUser({ name: "Bob", email: "bob@co.org", age: 25 });

// Chaining results
function parseAge(input: string): Result<number, string> {
  const num = Number(input);
  if (isNaN(num)) return err(`'${input}' is not a number`);
  if (!Number.isInteger(num)) return err("Must be a whole number");
  if (num < 0) return err("Cannot be negative");
  return ok(num);
}

console.log("\nParsing ages:");
["25", "abc", "30.5", "-1", "42"].forEach(input => {
  const result = parseAge(input);
  console.log(`  "${input}" -> ${result.ok ? result.value : `Error: ${result.error}`}`);
});

Exhaustive Checking with Never

The never type ensures all cases are handled.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };

function calculateArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

const shapes: Shape[] = [
  { kind: "circle", radius: 5 },
  { kind: "rectangle", width: 10, height: 3 },
  { kind: "triangle", base: 8, height: 6 }
];

shapes.forEach(shape => {
  console.log(`${shape.kind}: area = ${calculateArea(shape).toFixed(2)}`);
});

// Assert defined helper
function assertDefined<T>(value: T | undefined | null, name: string): T {
  if (value == null) throw new Error(`'${name}' must be defined`);
  return value;
}

const maybe: string | undefined = "hello";
console.log(`\nAsserted: ${assertDefined(maybe, "greeting")}`);

Try It Yourself

type MathError = "DIVISION_BY_ZERO" | "OVERFLOW" | "INVALID_INPUT";

type MathResult =
  | { ok: true; value: number }
  | { ok: false; error: MathError; message: string };

function safeDivide(a: number, b: number): MathResult {
  if (isNaN(a) || isNaN(b)) return { ok: false, error: "INVALID_INPUT", message: "Inputs must be valid numbers" };
  if (b === 0) return { ok: false, error: "DIVISION_BY_ZERO", message: "Cannot divide by zero" };
  const result = a / b;
  if (!isFinite(result)) return { ok: false, error: "OVERFLOW", message: "Result is not finite" };
  return { ok: true, value: result };
}

function formatResult(a: number, b: number): string {
  const result = safeDivide(a, b);
  if (result.ok) return `${a} / ${b} = ${result.value.toFixed(4)}`;
  switch (result.error) {
    case "DIVISION_BY_ZERO": return `${a} / ${b} -> ${result.message}`;
    case "OVERFLOW": return `${a} / ${b} -> ${result.message}`;
    case "INVALID_INPUT": return `Invalid -> ${result.message}`;
    default:
      const _: never = result.error;
      return `Unknown: ${_}`;
  }
}

console.log("Safe division:");
console.log(formatResult(10, 3));
console.log(formatResult(100, 0));
console.log(formatResult(NaN, 5));
console.log(formatResult(42, 7));

Key Takeaways

  • Caught errors are unknown — always narrow with instanceof before using
  • Custom error classes carry structured data for precise handling
  • The Result pattern makes errors explicit in the type system
  • The never type ensures exhaustive case handling
  • Use finally for cleanup that must always run
  • Use exceptions for bugs, Result for expected failures

Pro Tip: Use exceptions for programmer errors that should never happen in production. Use the Result pattern for expected failures like validation errors or missing resources. This distinction makes your code easier to reason about because the types tell you which functions can fail.

Next Steps

You've been writing your own types from scratch, but TypeScript ships with powerful built-in utilities that transform existing types. Next, you'll learn Partial, Pick, Omit, Record, and more — so you can derive new types without repeating yourself.

Next lesson

Utility Types

Master TypeScript utility types like Partial, Pick, Omit, and Record. Transform existing types without repetition.

22 min