Polyglot
Languages TypeScript error handling
TypeScript § error-handling

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 Error instances or subclasses.
  • Subclass Error for domain-specific error types.
  • Use the cause property for error chaining.
  • Use try/catch with async functions through await.
  • Use Promise.allSettled for batch operations with mixed success/failure.
  • Use try/finally or using for resource cleanup.
  • Enable useUnknownInCatchVariables for type-safe catch.
  • Narrow with instanceof in catch blocks.
  • Wrap errors at boundaries with explanatory context.
  • Use assert and asserts for 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.