Introduction
When a function can fail in multiple ways, you need typed error hierarchies — discriminated unions where each variant carries error-specific data. Combined with exhaustive switch statements and the never type, TypeScript ensures you handle every possible error.
Key Concepts
- Error Hierarchy: A union of error types, each with a unique
typediscriminant. - Exhaustive Switch: A switch statement that handles every variant of a union. TypeScript uses the
nevertype to verify completeness. - assertNever: A helper function that produces a compile error if a switch is not exhaustive.
Real World Context
A user registration flow can fail with invalid email, duplicate username, weak password, or rate limiting. Each error requires different handling (show field error, suggest alternatives, redirect to login). A typed error hierarchy ensures no failure mode is silently ignored.
Deep Dive
Defining an Error Hierarchy
typescripttype ValidationError = { type: "VALIDATION"; field: string; message: string; }; type DuplicateError = { type: "DUPLICATE"; entity: string; conflictingId: string; }; type NetworkError = { type: "NETWORK"; statusCode: number; retryable: boolean; }; type AppError = ValidationError | DuplicateError | NetworkError;
Exhaustive Handling with never
typescriptfunction assertNever(value: never): never { throw new Error(`Unhandled case: ${JSON.stringify(value)}`); } function handleError(error: AppError): string { switch (error.type) { case "VALIDATION": return `Field '${error.field}': ${error.message}`; case "DUPLICATE": return `${error.entity} already exists (${error.conflictingId})`; case "NETWORK": return error.retryable ? `Network error (${error.statusCode}), retrying...` : `Fatal network error: ${error.statusCode}`; default: return assertNever(error); } }
If you add a new error variant to AppError but forget to add a case, TypeScript will error on the assertNever(error) call because error will not be never — it will still have the unhandled variant's type.
Combining with Result
typescripttype Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; function registerUser(data: RegistrationData): Result<User, AppError> { if (!isValidEmail(data.email)) { return { ok: false, error: { type: "VALIDATION", field: "email", message: "Invalid format" } }; } // ... more checks return { ok: true, value: newUser }; }
Common Pitfalls
- Forgetting the default case — Without
assertNeverin the default branch, adding a new error variant will not produce a compile error, silently dropping the new case. - Using string instead of literal types —
type: stringprevents narrowing. Always use string literals liketype: "VALIDATION".
Best Practices
- Always include assertNever — Every exhaustive switch should have a
default: return assertNever(value)guard. - Group related errors — Create sub-unions for related errors (e.g.,
type AuthError = InvalidCredentials | Expired | Locked) and compose them into a top-levelAppError.
Summary
- Error hierarchies use discriminated unions with a
typestring literal. assertNeverin the default branch ensures exhaustive handling at compile time.- Adding a new error variant without a handler becomes a compile error.
- Compose sub-unions for modular error hierarchies.
Code Examples
typescript
function assertNever(value: never): never {
throw new Error(`Unhandled: ${JSON.stringify(value)}`);
}
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
default:
return assertNever(shape); // Compile error if a variant is missing
}
}