Error Handling Patterns
Design type-safe error handling with Result types, discriminated error unions, custom error classes, and the never-throw philosophy.
Overview
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.
Completed: 0 / 3 exercises
Knowledge Check
Question 1 of 4