Error handling
TypeScript inherits JavaScript’s exception model: throw, try/catch/finally, and the Error class hierarchy. The principal TypeScript additions are at the type level: under useUnknownInCatchVariables: true (since TS 4.4, default under strict), the catch variable is typed unknown rather than any — admitting type-safe error handling. The conventional discipline is mixed: exceptions for genuinely exceptional conditions, Result-style return types ({ ok: true; value: T } | { ok: false; error: E }) for expected failures. The Error class is conventionally subclassed for domain-specific error types; the cause property (since ES2022) admits structured error chains.
Throwing and catching
function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
try {
const result = divide(10, 0);
} catch (e) {
console.error(e); // logs the error
}
The catch block runs if the try block throws. The finally block runs whether or not an exception occurred:
try {
const f = openFile(path);
process(f);
} catch (e) {
console.error("Failed:", e);
} finally {
closeFile(); // always runs
}
The catch variable
Under useUnknownInCatchVariables: true (default since strict):
try {
riskyOperation();
} catch (e) {
// e is typed as unknown
if (e instanceof Error) {
console.log(e.message); // OK
} else {
console.log("unknown error");
}
}
Pre-useUnknownInCatchVariables, e was typed any — admitting any operations without type-checking. The unknown form admits substantial safety — every property access requires explicit narrowing.
Throwing values
JavaScript admits throwing any value, not just Error instances:
throw "string"; // legal but discouraged
throw 42; // also legal
throw { code: "EROFS", message: "read-only" }; // also legal
throw new Error("conventional"); // conventional
The conventional discipline always throws Error instances or subclasses; the alternatives produce inconsistent stack traces and break instanceof discrimination.
Custom error types
The conventional pattern is subclassing Error:
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = "ValidationError";
}
}
class NotFoundError extends Error {
constructor(public id: string) {
super(`Not found: ${id}`);
this.name = "NotFoundError";
}
}
throw new ValidationError("email", "invalid format");
The name assignment admits identifying the error type; instanceof admits discriminating in catch:
try {
process();
} catch (e) {
if (e instanceof ValidationError) {
return reportFieldError(e.field, e.message);
}
if (e instanceof NotFoundError) {
return notFoundResponse(e.id);
}
throw e; // re-throw unknown errors
}
Error chaining with cause
Since ES2022, Error admits a cause property:
try {
fetchData();
} catch (e) {
throw new Error("Failed to fetch", { cause: e });
}
// Inspecting:
try {
operation();
} catch (e) {
if (e instanceof Error && e.cause) {
console.log("caused by:", e.cause);
}
}
The mechanism admits substantial context preservation; the conventional discipline is to wrap errors at boundaries with explanatory messages.
try/catch and async
try/catch works with await:
async function fetchUser(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (e) {
console.error("fetchUser failed:", e);
return null;
}
}
The await propagates promise rejections as exceptions, admitting the conventional try/catch pattern across async boundaries.
For unhandled promise rejections, the runtime emits an unhandledrejection event:
window.addEventListener("unhandledrejection", e => {
console.error("Unhandled:", e.reason);
});
// Node.js:
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled:", reason);
});
Result-style returns
For expected failures, the Result pattern admits explicit error handling without exceptions:
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 };
}
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return err("division by zero");
return ok(a / b);
}
const r = divide(10, 2);
if (r.ok) {
console.log(r.value); // 5
} else {
console.error(r.error);
}
The pattern admits compile-time enforcement of error handling; treated in Narrowing.
For libraries providing the pattern: neverthrow, effect, fp-ts (Either).
unknown in catch — narrowing patterns
try {
operation();
} catch (e) {
// Pattern 1: instanceof
if (e instanceof Error) {
console.error(e.message, e.stack);
}
// Pattern 2: type predicate
if (isApiError(e)) {
return e.status;
}
// Pattern 3: shape check
if (typeof e === "object" && e !== null && "message" in e) {
console.error((e as { message: unknown }).message);
}
// Pattern 4: conversion
const error = e instanceof Error ? e : new Error(String(e));
throw error;
}
function isApiError(e: unknown): e is { status: number; body: unknown } {
return (
typeof e === "object" && e !== null &&
"status" in e && typeof (e as { status: unknown }).status === "number"
);
}
The conventional defence admits clean narrowing in catch blocks.
assert patterns
For preconditions and invariants:
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message ?? "Assertion failed");
}
}
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
function process(x: unknown): string {
assertIsString(x);
return x.toUpperCase(); // narrowed to string
}
Treated in Narrowing.
Common patterns
Error wrapping with context
async function loadConfig(path: string): Promise<Config> {
try {
const data = await readFile(path, "utf8");
return JSON.parse(data) as Config;
} catch (e) {
throw new Error(`Failed to load config from ${path}`, { cause: e });
}
}
Error type hierarchy
abstract class AppError extends Error {
abstract readonly code: string;
}
class NetworkError extends AppError {
readonly code = "NETWORK_ERROR";
constructor(message: string, public statusCode: number) {
super(message);
this.name = "NetworkError";
}
}
class ValidationError extends AppError {
readonly code = "VALIDATION_ERROR";
constructor(message: string, public field: string) {
super(message);
this.name = "ValidationError";
}
}
Discriminated error handling
function handle(error: unknown): string {
if (error instanceof NetworkError) {
return `Network: ${error.statusCode} ${error.message}`;
}
if (error instanceof ValidationError) {
return `Validation: ${error.field} - ${error.message}`;
}
if (error instanceof Error) {
return `Error: ${error.message}`;
}
return `Unknown: ${String(error)}`;
}
Result with helpers
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 }; }
function map<T, U, E>(r: Result<T, E>, f: (x: T) => U): Result<U, E> {
return r.ok ? ok(f(r.value)) : r;
}
async function tryAsync<T>(fn: () => Promise<T>): Promise<Result<T>> {
try {
const value = await fn();
return ok(value);
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
// Usage:
const result = await tryAsync(() => fetchUser(id));
if (result.ok) {
console.log(result.value.name);
} else {
console.error(result.error.message);
}
Retry with exponential backoff
async function retry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; baseDelay?: number } = {},
): Promise<T> {
const { maxAttempts = 3, baseDelay = 100 } = options;
let lastError: unknown;
for (let i = 0; i < maxAttempts; i++) {
try {
return await fn();
} catch (e) {
lastError = e;
if (i < maxAttempts - 1) {
const delay = baseDelay * 2 ** i;
await new Promise(r => setTimeout(r, delay));
}
}
}
throw new Error(`Failed after ${maxAttempts} attempts`, { cause: lastError });
}
Resource cleanup with try/finally
async function withFile<T>(
path: string,
fn: (handle: FileHandle) => Promise<T>,
): Promise<T> {
const handle = await open(path);
try {
return await fn(handle);
} finally {
await handle.close();
}
}
Using TC39 explicit resource management (TS 5.2+)
class Resource implements Disposable {
[Symbol.dispose]() {
// cleanup
}
}
class AsyncResource implements AsyncDisposable {
async [Symbol.asyncDispose]() {
// async cleanup
}
}
{
using r = new Resource(); // disposes when scope ends
// use r
} // r[Symbol.dispose]() called
{
await using r = new AsyncResource();
// use r
} // await r[Symbol.asyncDispose]()
The using keyword is the conventional contemporary form for resource management.
Validation with errors
class ValidationError extends Error {
constructor(public errors: Array<{ field: string; message: string }>) {
super(`Validation failed: ${errors.length} error(s)`);
this.name = "ValidationError";
}
}
function validateUser(user: unknown): User {
const errors: Array<{ field: string; message: string }> = [];
if (typeof user !== "object" || user === null) {
throw new ValidationError([{ field: "root", message: "must be an object" }]);
}
const u = user as Record<string, unknown>;
if (typeof u.name !== "string") {
errors.push({ field: "name", message: "must be a string" });
}
if (typeof u.age !== "number" || u.age < 0) {
errors.push({ field: "age", message: "must be a non-negative number" });
}
if (errors.length > 0) {
throw new ValidationError(errors);
}
return u as unknown as User;
}
Promise.allSettled for collecting errors
const results = await Promise.allSettled(
urls.map(url => fetch(url).then(r => r.json())),
);
const successful = results.filter(r => r.status === "fulfilled").map(r => r.value);
const errors = results.filter(r => r.status === "rejected").map(r => r.reason);
The mechanism admits “do all of these; report what succeeded and what failed”.
Custom error classes that work with instanceof
A subtle JavaScript pitfall: extending Error requires care for instanceof to work in some environments:
class MyError extends Error {
constructor(message: string) {
super(message);
this.name = "MyError";
// Required for prototype-chain based instanceof in older runtimes:
Object.setPrototypeOf(this, MyError.prototype);
}
}
const e = new MyError("oops");
console.log(e instanceof MyError); // true
The Object.setPrototypeOf(this, ...) line is conventional in target environments where the transpilation breaks the prototype chain (older browsers, certain Babel configurations).
A note on throw-as-expression
JavaScript’s throw is a statement, not an expression — it cannot appear in arrow function bodies or ternaries:
// Cannot:
// const x = condition ? value : throw new Error("...");
// Use a function:
function fail(message: string): never {
throw new Error(message);
}
const x = condition ? value : fail("expected condition");
The TC39 throw expressions proposal (Stage 2 as of 2026) admits throw as an expression.
A note on the conventional discipline
The contemporary TypeScript error-handling advice:
- Use exceptions for genuinely exceptional conditions.
- Use Result types for expected failures and substantial type safety.
- Always throw
Errorinstances or subclasses. - Subclass
Errorfor domain-specific error types. - Use the
causeproperty for error chaining. - Use
try/catchwith async functions throughawait. - Use
Promise.allSettledfor batch operations with mixed success/failure. - Use
try/finallyorusingfor resource cleanup. - Enable
useUnknownInCatchVariablesfor type-safe catch. - Narrow with
instanceofin catch blocks. - Wrap errors at boundaries with explanatory context.
- Use
assertandassertsfor preconditions.
The combination — JavaScript’s exception model, TypeScript’s unknown-typed catch, error subclassing, the cause property, Result-style returns for expected failures, the using form for resource management — is the substance of TypeScript’s error-handling surface. The discipline produces traceable, type-safe error handling that admits both exception-style and value-style failure handling.