Skip to content
Home
JavaScript Error Handling: Best Practices

JavaScript Error Handling: Best Practices

JavaScript JavaScript 7 min read 1467 words Beginner ExcellentWiki Editorial Team

Error handling separates robust applications from fragile ones. JavaScript’s error handling capabilities include try/catch/finally, custom error classes, the Error object with stack traces, and integration with async patterns like promises and async/await.

The Error Object

// Creating and throwing errors
throw new Error('Something went wrong');
throw new TypeError('Expected a number');
throw new RangeError('Value out of bounds');
throw new SyntaxError('Invalid syntax');

// Error properties
const error = new Error('User not found');
console.log(error.message);    // 'User not found'
console.log(error.name);       // 'Error'
console.log(error.stack);      // stack trace (v8/non-standard)

Always use Error subclasses (TypeError, RangeError, ReferenceError, SyntaxError) instead of plain strings. String throws lose stack trace information and make debugging much harder — they do not carry line numbers or call chain context.

Try/Catch/Finally

try {
    const result = riskyOperation();
    console.log(result);
--- catch (error) {
    console.error('Operation failed:', error.message);
    // Handle or re-throw
    throw error; // re-throw if cant handle
--- finally {
    cleanup();
    // Always runs — even after return, break, or throw
---

Selective Catching

JavaScript does not support typed catch clauses, so check the error type manually:

try {
    const data = JSON.parse(input);
    validateData(data);
--- catch (error) {
    if (error instanceof SyntaxError) {
        console.error('Invalid JSON input');
    } else if (error instanceof ValidationError) {
        console.error('Validation failed:', error.fields);
    } else {
        throw error; // re-throw unexpected errors
    }
---

Always re-throw errors you cannot handle. Swallowing unexpected errors hides bugs and makes debugging impossible. Let unhandled errors propagate to a global handler or crash the process.

Custom Error Classes

class AppError extends Error {
    constructor(message, code = 500, details = {}) {
        super(message);
        this.name = 'AppError';
        this.code = code;
        this.details = details;
        this.timestamp = new Date().toISOString();
    }
---

class ValidationError extends AppError {
    constructor(fields) {
        super('Validation failed', 400, { fields });
        this.name = 'ValidationError';
    }
---

class NotFoundError extends AppError {
    constructor(resource, id) {
        super(`${resource} not found: ${id}`, 404);
        this.name = 'NotFoundError';
    }
---

// Usage
throw new ValidationError({ email: 'Invalid format', age: 'Required' });

Custom errors carry semantic meaning and structured data. They make error handling at higher levels simpler — a single catch can distinguish between validation errors (return 400), not-found errors (return 404), and server errors (return 500).

Async Error Handling

Promises

// Promise rejection
fetch('/api/data')
    .then(response => {
        if (!response.ok) {
            throw new HTTPError(response.status, response.statusText);
        }
        return response.json();
    })
    .catch(error => {
        console.error('Fetch failed:', error);
        return fallbackData;
    });

// Promise.all — all or nothing
try {
    const [user, posts] = await Promise.all([
        fetchUser(id),
        fetchPosts(id),
    ]);
--- catch (error) {
    // Any rejection triggers catch
    console.error('Failed to load profile data');
---

// Promise.allSettled — wait for all regardless
const results = await Promise.allSettled([
    fetchUser(id),
    fetchPosts(id),
]);
for (const result of results) {
    if (result.status === 'rejected') {
        console.error('Operation failed:', result.reason);
    }
---

Promise.allSettled is preferable when you want partial success — it does not short-circuit on rejection. Promise.any returns the first fulfilled promise (or rejects if all reject), useful for racing against multiple sources.

Async/Await

async function getUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            throw new APIError(
                `HTTP ${response.status}`,
                response.status
            );
        }
        return await response.json();
    } catch (error) {
        if (error instanceof APIError) {
            // Known API error — log and re-throw with context
            console.error(`API error for user ${userId}:`, error);
            throw error;
        }
        // Network or other error — wrap in AppError
        throw new AppError('Failed to fetch user data', 503, { userId });
    }
---

Async/await with try/catch reads linearly but handle errors at the right abstraction level. Wrap low-level errors in domain-specific errors at service boundaries.

Global Error Handlers

// Browser
window.addEventListener('error', (event) => {
    console.error('Uncaught error:', event.error);
    sendToErrorMonitoring(event.error);
    event.preventDefault(); // prevent default console output
---);

window.addEventListener('unhandledrejection', (event) => {
    console.error('Unhandled promise rejection:', event.reason);
    sendToErrorMonitoring(event.reason);
    event.preventDefault();
---);

// Node.js
process.on('uncaughtException', (error) => {
    console.error('Uncaught exception:', error);
    // Perform cleanup
    // Exit with failure
    process.exit(1);
---);

process.on('unhandledRejection', (reason, promise) => {
    console.error('Unhandled rejection at:', promise, 'reason:', reason);
    // In Node 15+, unhandled rejections crash the process
---);

Global handlers are a safety net for unexpected errors. They should log, send to monitoring, and ideally restart the process — not try to recover normal operation. A crashed process in an inconsistent state is worse than a clean restart.

Defensive Programming

// Guard clauses
function processUser(user) {
    if (!user) {
        throw new TypeError('User is required');
    }
    if (typeof user.id !== 'number') {
        throw new TypeError('User.id must be a number');
    }
    // ... proceed safely
---

// Optional chaining and nullish coalescing
const city = user?.address?.city ?? 'Unknown';

// Default parameters
function fetchData(url, options = {}) {
    const { timeout = 5000, retries = 3 } = options;
    // ...
---

// Input validation at boundaries
function parseConfig(json) {
    try {
        const config = JSON.parse(json);
        if (typeof config.port !== 'number') {
            throw new Error('port must be a number');
        }
        return config;
    } catch (error) {
        throw new ConfigError('Invalid configuration', { original: error });
    }
---

Defensive programming validates assumptions early. Guard clauses at the top of functions fail fast instead of producing confusing errors deep in the call stack.

Error Wrapping Pattern

Wrap low-level errors in higher-level abstractions to preserve context:

class ServiceError extends Error {
    constructor(message, original, context) {
        super(message);
        this.name = 'ServiceError';
        this.original = original;   // original error for debugging
        this.context = context;     // app-level context
    }
---

async function getUserPreferences(userId) {
    try {
        const data = await db.query('SELECT * FROM preferences WHERE user_id = ?', [userId]);
        return data;
    } catch (error) {
        throw new ServiceError('Failed to load preferences', error, {
            userId,
            operation: 'getUserPreferences',
        });
    }
---

Error wrapping preserves the original stack trace while adding application context. This is essential in layered architectures where a database error deep in the stack needs context about what the application was doing.

Logging Best Practices

function logError(error, context = {}) {
    const logEntry = {
        timestamp: new Date().toISOString(),
        name: error.name,
        message: error.message,
        stack: error.stack,
        ...context,
    };

    // Structured logging
    if (process.env.NODE_ENV === 'production') {
        console.error(JSON.stringify(logEntry));
    } else {
        console.error(`[${logEntry.timestamp}] ${error.name}: ${error.message}`);
        console.error(error.stack);
    }
---

Structured Error Handling with Result Types

Some codebases prefer Result types over exceptions for predictable error handling:

class Result {
    static ok(value) {
        return new Result(true, value, null);
    }

    static fail(error) {
        return new Result(false, null, error);
    }

    constructor(success, value, error) {
        this.success = success;
        this.value = value;
        this.error = error;
    }

    isOk() { return this.success; }
    isFail() { return !this.success; }

    unwrap() {
        if (this.isFail()) throw this.error;
        return this.value;
    }

    map(fn) {
        return this.isOk() ? Result.ok(fn(this.value)) : this;
    }

    catch(fn) {
        return this.isFail() ? fn(this.error) : this;
    }
---

// Usage
async function divide(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
        return Result.fail(new TypeError('Numbers required'));
    }
    if (b === 0) {
        return Result.fail(new Error('Division by zero'));
    }
    return Result.ok(a / b);
---

const result = await divide(10, 2);
result
    .map(value => value * 2)
    .catch(error => console.error('Failed:', error.message));

Error Aggregation

Aggregate multiple errors for batch operations:

class AggregateError extends Error {
    constructor(errors) {
        super(`Multiple errors (${errors.length} total)`);
        this.name = 'AggregateError';
        this.errors = errors;
    }
---

async function processBatch(items) {
    const errors = [];
    const results = [];

    for (const item of items) {
        try {
            results.push(await process(item));
        } catch (error) {
            errors.push({ item, error });
        }
    }

    if (errors.length > 0) {
        throw new AggregateError(errors);
    }
    return results;
---

FAQ

Q: Should I catch all errors in every function? A: No. Catch errors at the boundaries of your system (API handlers, event listeners, worker callbacks). Let errors propagate through internal functions and catch them where you can meaningfully handle or report them.

Q: What is the difference between throw and return error? A: throw unwinds the stack and propagates up to the nearest catch. Returned errors require the caller to check each return value — easy to forget. Use throw for exceptional conditions and return errors only for expected failure modes.

Q: How do I get a stack trace in production? A: Stack traces are available on all Error instances. Send them to your error monitoring service (Sentry, Datadog, etc.). Source maps allow you to translate minified stack traces back to original source.

Q: Should I handle promise rejections in every .then? A: No. A single catch at the end of a promise chain handles all rejections from preceding .then handlers. Add individual catches only when you need to handle specific errors at specific points.

Q: What is the best practice for async error handling? A: Use async/await with try/catch. It produces linear code and works with standard error handling patterns. Always handle promise rejections — unhandled rejections crash Node.js processes.

Q: How do I test error handling? A: Mock the error-producing code paths. Assert that the function throws the expected error type with the expected message. Use expect(fn).toThrow() in Jest or assert.throws in Node.

For a comprehensive overview, read our article on Javascript Arrays Guide.

For a comprehensive overview, read our article on Javascript Async Await.

Section: JavaScript 1467 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top