Polyglot
Languages Web (HTML / CSS / JS) error handling
Web (HTML / CSS / JS) § error-handling

Error handling

JavaScript admits exceptions as the principal error-handling mechanism — throw a value (conventionally an Error), catch in a try/catch block. The principal builtins: Error, TypeError, RangeError, SyntaxError, ReferenceError, URIError, EvalError. Custom errors extend Error. For async errors, try/catch works around await; unhandled Promise rejections fire unhandledrejection. Browser-specific patterns: defensive UI (validation), user-visible errors (toasts, error boundaries in frameworks), telemetry (window.onerror, unhandledrejection). The combination — try/catch with Error-derived classes, async/await integration, the conventional custom-error pattern, the substantial top-level error reporting (onerror, unhandledrejection), the substantial framework error boundaries — is the substance of JavaScript error handling.

throw and try/catch

function divide(a, b) {
    if (b === 0) {
        throw new Error("Division by zero");
    }
    return a / b;
}

try {
    const result = divide(10, 0);
} catch (err) {
    console.error(err.message);
} finally {
    console.log("done");
}

The principal points:

  • throw admits any value but should be an Error.
  • catch binds the thrown value to a parameter (parameter optional in modern JS).
  • finally runs regardless of throw or no-throw.
  • Stack frames unwind through enclosing function calls until caught.
// Optional binding (ES2019):
try {
    risky();
} catch {
    // err not used
}

Error and built-in subclasses

new Error("message");
new TypeError("not a function");
new RangeError("out of range");
new SyntaxError("...");
new ReferenceError("undefined variable");
new URIError("malformed URI");
new EvalError("eval error");
new AggregateError([err1, err2], "all rejected");

// Properties:
const err = new Error("oops");
err.message;                                       // "oops"
err.name;                                          // "Error"
err.stack;                                         // multi-line stack trace (non-standard but ubiquitous)
err.cause;                                         // ES2022 — chained error

Error cause

try {
    await doWork();
} catch (err) {
    throw new Error("Failed to process request", { cause: err });
}

// Reading:
try {
    handler();
} catch (err) {
    console.log(err.message);
    console.log(err.cause?.message);
}

Custom errors

class ValidationError extends Error {
    constructor(message, field) {
        super(message);
        this.name = "ValidationError";
        this.field = field;
    }
}

class HttpError extends Error {
    constructor(message, status, body) {
        super(message);
        this.name = "HttpError";
        this.status = status;
        this.body = body;
    }
}

class NetworkError extends Error {
    constructor(message, cause) {
        super(message, { cause });
        this.name = "NetworkError";
    }
}

// Usage:
try {
    throw new ValidationError("Email is required", "email");
} catch (err) {
    if (err instanceof ValidationError) {
        showFieldError(err.field, err.message);
    } else {
        throw err;                                 // re-throw if not handled
    }
}

Async errors

async function fetchData() {
    try {
        const r = await fetch("/api/data");
        if (!r.ok) throw new HttpError(`HTTP ${r.status}`, r.status);
        return await r.json();
    } catch (err) {
        if (err instanceof HttpError) {
            // handle HTTP
        } else if (err instanceof TypeError) {
            // network error (fetch rejects with TypeError)
        }
        throw err;
    }
}

The await integrates with try/catch — substantial cleaner than callback chains.

Promise without await

fetch("/api/data")
    .then(r => r.json())
    .then(data => console.log(data))
    .catch(err => console.error(err));

Mixed

const data = await fetch("/api/data")
    .then(r => r.ok ? r.json() : Promise.reject(new HttpError(`HTTP ${r.status}`, r.status)));

try/catch around await

async function load() {
    let data;
    try {
        data = await fetchData();
    } catch (err) {
        console.error(err);
        return null;                               // graceful fallback
    }

    return process(data);
}

Re-throwing

try {
    risky();
} catch (err) {
    if (err instanceof KnownError) {
        handle(err);
    } else {
        throw err;                                 // unknown, re-throw
    }
}

Wrap and re-throw

try {
    await fetchUser(id);
} catch (err) {
    throw new Error(`Failed to fetch user ${id}`, { cause: err });
}

Top-level error handlers

For unhandled errors:

window.addEventListener("error", (e) => {
    console.error("Uncaught:", e.error);
    console.error("File:", e.filename, "Line:", e.lineno);
    sendTelemetry({
        type: "uncaught",
        message: e.message,
        file: e.filename,
        line: e.lineno,
        stack: e.error?.stack
    });
});

window.addEventListener("unhandledrejection", (e) => {
    console.error("Unhandled rejection:", e.reason);
    sendTelemetry({
        type: "rejection",
        reason: String(e.reason),
        stack: e.reason?.stack
    });
    e.preventDefault();                            // prevent default console error
});

The conventional contemporary discipline reports all such errors to a telemetry service (Sentry, Bugsnag, Honeybadger, custom).

Error boundary (React)

class ErrorBoundary extends React.Component {
    state = { error: null };

    static getDerivedStateFromError(error) {
        return { error };
    }

    componentDidCatch(error, info) {
        sendTelemetry({ error, info });
    }

    render() {
        if (this.state.error) {
            return <div>Something went wrong.</div>;
        }
        return this.props.children;
    }
}

// Use:
<ErrorBoundary>
    <App />
</ErrorBoundary>

Other frameworks have substantial similar patterns (Vue’s errorCaptured, Svelte’s error boundaries, etc.).

Validation patterns

function validateUser(user) {
    const errors = [];

    if (!user.name) errors.push({ field: "name", message: "Name is required" });
    if (user.age < 0) errors.push({ field: "age", message: "Age must be positive" });
    if (!user.email?.includes("@")) errors.push({ field: "email", message: "Invalid email" });

    if (errors.length > 0) {
        const err = new ValidationError("Invalid user");
        err.errors = errors;
        throw err;
    }
}

try {
    validateUser(user);
    save(user);
} catch (err) {
    if (err instanceof ValidationError) {
        for (const e of err.errors) {
            showFieldError(e.field, e.message);
        }
    } else {
        throw err;
    }
}

For substantial validation, the conventional contemporary discipline uses a library:

// Zod:
import { z } from "zod";

const UserSchema = z.object({
    name: z.string().min(1),
    age: z.number().int().nonnegative(),
    email: z.string().email()
});

const result = UserSchema.safeParse(input);
if (!result.success) {
    for (const issue of result.error.issues) {
        console.log(issue.path, issue.message);
    }
}

Result/Option patterns (manual)

JavaScript does not natively admit Result/Option — but the pattern is substantial:

function tryFn(fn) {
    try {
        return [null, fn()];
    } catch (err) {
        return [err, null];
    }
}

const [err, value] = tryFn(() => JSON.parse(input));
if (err) {
    console.error(err);
} else {
    console.log(value);
}

// Async:
async function tryAsync(fn) {
    try {
        return [null, await fn()];
    } catch (err) {
        return [err, null];
    }
}

const [err, data] = await tryAsync(() => fetch("/api").then(r => r.json()));

For substantial typed result handling, the conventional contemporary discipline uses TypeScript with discriminated unions or a Result library (neverthrow, oxide.ts).

Common patterns

Defensive parsing

function parseInt(text, fallback = 0) {
    const n = Number(text);
    return Number.isFinite(n) ? Math.trunc(n) : fallback;
}

function parseJson(text, fallback = null) {
    try {
        return JSON.parse(text);
    } catch {
        return fallback;
    }
}

Wrapped async with default

async function safeFetch(url, fallback = null) {
    try {
        const r = await fetch(url);
        if (!r.ok) return fallback;
        return await r.json();
    } catch {
        return fallback;
    }
}

Retry with logging

async function retry(fn, attempts = 3) {
    for (let i = 0; i <= attempts; i++) {
        try {
            return await fn();
        } catch (err) {
            if (i === attempts) {
                console.error(`failed after ${attempts} attempts:`, err);
                throw err;
            }
            console.warn(`attempt ${i + 1} failed, retrying:`, err.message);
            await delay(2 ** i * 100);
        }
    }
}

Graceful degradation

async function getProfile(id) {
    try {
        return await fetchProfile(id);
    } catch (err) {
        if (err instanceof NetworkError) {
            return getCachedProfile(id) ?? defaultProfile();
        }
        throw err;
    }
}

User-visible toast on error

async function withErrorToast(fn) {
    try {
        return await fn();
    } catch (err) {
        if (err instanceof ValidationError) {
            showToast(err.message, "warning");
        } else if (err instanceof HttpError) {
            showToast(`Server error: ${err.status}`, "error");
        } else {
            showToast("Something went wrong", "error");
            sendTelemetry(err);
        }
        throw err;
    }
}

await withErrorToast(() => save(data));

Form submission

form.addEventListener("submit", async (e) => {
    e.preventDefault();
    const submitBtn = form.querySelector("button[type='submit']");

    try {
        submitBtn.disabled = true;
        clearErrors();

        const data = Object.fromEntries(new FormData(form));
        validate(data);                            // throws ValidationError
        await submit(data);                        // throws HttpError or NetworkError

        showToast("Saved!", "success");
        form.reset();
    } catch (err) {
        if (err instanceof ValidationError) {
            for (const issue of err.errors) {
                showFieldError(issue.field, issue.message);
            }
        } else if (err instanceof HttpError && err.status === 409) {
            showToast("Conflict — please refresh", "warning");
        } else {
            showToast("Failed to save", "error");
            sendTelemetry(err);
        }
    } finally {
        submitBtn.disabled = false;
    }
});

Centralised error reporter

class ErrorReporter {
    #endpoint;
    #queue = [];

    constructor(endpoint) {
        this.#endpoint = endpoint;
        window.addEventListener("error", e => this.report({
            type: "uncaught",
            message: e.message,
            file: e.filename,
            line: e.lineno,
            stack: e.error?.stack
        }));
        window.addEventListener("unhandledrejection", e => this.report({
            type: "rejection",
            reason: String(e.reason),
            stack: e.reason?.stack
        }));
    }

    report(error) {
        this.#queue.push({
            ...error,
            timestamp: Date.now(),
            url: location.href,
            userAgent: navigator.userAgent
        });
        this.#flush();
    }

    async #flush() {
        if (this.#queue.length === 0) return;
        const batch = this.#queue.splice(0);
        try {
            await fetch(this.#endpoint, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify(batch),
                keepalive: true
            });
        } catch {
            this.#queue.unshift(...batch);         // retry next time
        }
    }
}

const reporter = new ErrorReporter("/api/errors");

Type guard (TypeScript)

function isHttpError(err: unknown): err is HttpError {
    return err instanceof HttpError;
}

try {
    await fetchData();
} catch (err: unknown) {
    if (isHttpError(err)) {
        console.log(err.status);
    } else if (err instanceof Error) {
        console.log(err.message);
    } else {
        console.log("unknown:", err);
    }
}

Promise rejection in event handler

button.addEventListener("click", async () => {
    try {
        await doWork();
    } catch (err) {
        showToast(err.message);
    }
});

The conventional discipline always wraps async event handlers in try/catch — admit substantial control over user-facing reporting.

Dialog with error

async function confirmAction() {
    const ok = await showConfirmDialog("Are you sure?");
    if (!ok) return;

    try {
        await deleteItem();
        showToast("Deleted", "success");
    } catch (err) {
        await showErrorDialog(`Failed: ${err.message}`);
    }
}

Timeout-handling

async function withTimeout(promise, ms) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), ms);

    try {
        return await Promise.race([
            promise,
            new Promise((_, reject) =>
                controller.signal.addEventListener("abort", () =>
                    reject(new Error("Timeout"))
                )
            )
        ]);
    } finally {
        clearTimeout(timer);
    }
}

Catching specific HTTP status

try {
    await api("/users/42");
} catch (err) {
    if (err instanceof HttpError) {
        switch (err.status) {
            case 401:
                redirectToLogin();
                break;
            case 403:
                showToast("Forbidden");
                break;
            case 404:
                showNotFoundPage();
                break;
            case 500:
                showToast("Server error, please try again");
                sendTelemetry(err);
                break;
            default:
                showToast(`Error ${err.status}`);
        }
    } else {
        throw err;
    }
}

A note on “throwing strings”

// Avoid — admits substantial less information:
throw "something failed";

// Prefer:
throw new Error("something failed");

The Error constructor admits stack traces; raw strings do not.

A note on console.error

console.error("error:", err);                      // prints to console
console.warn("warning:", info);
console.info("info:", info);
console.debug("debug:", info);

// With stack:
console.trace("here:", info);

// Group:
console.group("processing");
console.log("step 1");
console.log("step 2");
console.groupEnd();

The conventional production discipline removes or guards console.log (lint rule); leaves console.error/warn for substantial diagnostic.

A note on the conventional discipline

The contemporary error handling advice:

  • Throw Error (or subclass) — never strings or numbers.
  • Use custom error classes — admit substantial instanceof discrimination.
  • Use cause (ES2022) for substantial chained errors.
  • Use try/catch around await — substantial cleaner than .catch.
  • Use unhandledrejection and error events for substantial telemetry.
  • Use a reporter (Sentry, Bugsnag, custom) for substantial production.
  • Use validation libraries (Zod, Yup, Joi) for substantial input validation.
  • Use error boundaries (React, Vue) for substantial UI fail-safes.
  • Re-throw unknown errors — only catch what you handle.
  • Always show user-visible feedback on async errors.
  • Always log to telemetry on uncaught/rejected.
  • Avoid swallowing errors silently — log even if not user-visible.

The combination — try/catch with Error-derived classes, async/await integration, custom error classes for substantial discrimination, cause for chains, top-level handlers for substantial telemetry, framework error boundaries for substantial UI safety, validation libraries for substantial input checking — is the substance of JavaScript error handling. The discipline produces robust, observable, user-friendly applications.