⚡IntermediateAsync & Error Handling

Error Handling Patterns

Design type-safe error handling with Result types, discriminated error unions, custom error classes, and the never-throw philosophy.

Overview

JavaScript's `try/catch` gives TypeScript a blind spot: the caught value has type `unknown` because *anything* can be thrown. This makes error handling imprecise - you cannot know what you caught without checking. Type-safe error handling patterns give errors first-class type status: **Custom error classes** with discriminated `name` fields let you use `instanceof` narrowing precisely. **Result types** (also called Either/Option patterns) make errors explicit in the return type: `Result<T, E>` is either `{ ok: true; value: T }` or `{ ok: false; error: E }`. No exceptions thrown - the caller must handle both cases. This is the approach used by Rust, Go, and many TypeScript frameworks. **Discriminated error unions** let a function explicitly declare all the errors it can produce: `Promise<{ ok: true; user: User } | { ok: false; error: NotFoundError | ValidationError }>`. The caller uses exhaustive narrowing to handle each case.

Key Points

  • •Catch variable is `unknown` in strict mode - always narrow with `instanceof Error` before use
  • •Custom error classes: `class NotFoundError extends Error { name = "NotFoundError" as const }` - enables `instanceof` narrowing
  • •Result type: `type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }`
  • •Result avoids thrown exceptions - errors are values returned, not thrown; caller MUST handle them
  • •`never` in switch exhaustion: add `default: throw new Error("Unhandled: " + (x satisfies never))` to catch missing cases at compile time
  • •`satisfies never` (TypeScript 4.9+) - asserts a value should never exist, giving better error messages
  • •Error boundaries: `assertIsError(e: unknown): asserts e is Error` - assertion functions narrow types

Examples

Custom error classes and instanceof narrowing

// Named custom errors for precise narrowing
class NotFoundError extends Error {
  readonly name = 'NotFoundError' as const;
  constructor(public resource: string, public id: number) {
    super(`${resource} with id ${id} not found`);
  }
}

class ValidationError extends Error {
  readonly name = 'ValidationError' as const;
  constructor(public field: string, public reason: string) {
    super(`Validation failed for ${field}: ${reason}`);
  }
}

class UnauthorizedError extends Error {
  readonly name = 'UnauthorizedError' as const;
}

// Union of all possible errors
type AppError = NotFoundError | ValidationError | UnauthorizedError;

async function getUser(id: number): Promise<{ id: number; name: string }> {
  if (id <= 0) throw new ValidationError('id', 'must be positive');
  if (id > 100) throw new NotFoundError('User', id);
  return { id, name: `User${id}` };
}

try {
  const user = await getUser(-1);
  console.log(user.name);
} catch (e) {
  if (e instanceof ValidationError) {
    console.error(`Invalid ${e.field}: ${e.reason}`); // typed fields
  } else if (e instanceof NotFoundError) {
    console.error(`Not found: ${e.resource} #${e.id}`);
  } else if (e instanceof Error) {
    console.error('Unexpected:', e.message);
  }
}

Result type pattern - errors as values

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

// Helper constructors
const ok  = <T>(value: T): Result<T, never>     => ({ ok: true,  value });
const err = <E>(error: E): Result<never, E>      => ({ ok: false, error });

// Functions return Result  -  never throw
async function parseJSON<T>(text: string): Promise<Result<T, SyntaxError>> {
  try {
    return ok(JSON.parse(text) as T);
  } catch (e) {
    return err(e as SyntaxError);
  }
}

async function findUser(id: number): Promise<Result<User, NotFoundError>> {
  const users: User[] = [{ id: 1, name: 'Alice', email: 'a@a.com' }];
  const user = users.find(u => u.id === id);
  if (!user) return err(new NotFoundError('User', id));
  return ok(user);
}

// Caller must handle both cases  -  cannot accidentally ignore the error
const result = await findUser(99);
if (result.ok) {
  console.log(result.value.name); // User  -  typed
} else {
  console.error(result.error.message); // NotFoundError  -  typed
}

interface User { id: number; name: string; email: string }
class NotFoundError extends Error { constructor(r: string, id: number) { super(`${r} ${id} not found`); }}

Assertion functions and exhaustive error handling

// Type assertion function  -  narrows e to Error
function assertIsError(e: unknown): asserts e is Error {
  if (!(e instanceof Error)) {
    throw new TypeError(`Expected an Error but got: ${typeof e}`);
  }
}

try {
  throw new Error('Something failed');
} catch (e) {
  assertIsError(e);
  // After the assertion, TypeScript knows e is Error
  console.log(e.message); // No type error!
  console.log(e.stack);
}

// Exhaustive switch with never
type Status = 'pending' | 'active' | 'suspended' | 'deleted';

function getStatusMessage(status: Status): string {
  switch (status) {
    case 'pending':   return 'Awaiting verification';
    case 'active':    return 'Account is active';
    case 'suspended': return 'Account suspended';
    case 'deleted':   return 'Account deleted';
    default:
      // TypeScript errors here if a Status case is missing
      const _exhaustive: never = status;
      throw new Error(`Unhandled status: ${_exhaustive}`);
  }
}

Common Mistakes

  • Catching `e` and immediately accessing `e.message` without narrowing - in strict mode `e` is `unknown`; always check `instanceof Error` first
  • Creating custom errors without `super(message)` in the constructor - Error's message property is only set by calling `super()`
  • Using Result types inconsistently - mixing throwing functions with Result-returning functions makes caller code confusing; pick one style per boundary

Note

The Result pattern works best at I/O boundaries (database calls, HTTP requests, file reads) where failure is expected. For truly exceptional, unrecoverable errors (programming bugs, null pointer equivalents), `throw` is still appropriate. Think: "is this failure part of the normal operation flow?" - if yes, use Result; if no, throw.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 3 exercises

Knowledge Check

Question 1 of 4

In TypeScript strict mode, what type does `e` have in `catch (e) { }`?