Error Handling

โ–ถ Try It Yourself

Errors are inevitable in real applications โ€” networks fail, users provide invalid input, APIs return unexpected data, and code has bugs. The difference between a professional application and a fragile one is how it handles errors. JavaScript provides try/catch/finally for synchronous error handling, the Error object hierarchy for structured errors, and patterns for propagating and handling errors in async code. In this lesson you will build a solid mental model for error handling that applies from simple scripts to production applications.

Error Handling Constructs

Construct Purpose Always Runs?
try { } Wrap code that might throw Yes โ€” always starts here
catch (err) { } Handle the error โ€” err is the thrown value Only if try block throws
finally { } Cleanup โ€” runs whether or not an error occurred Always โ€” even after return
throw expression Manually throw any value as an error Immediately exits current block

Built-in Error Types

Error Type When It Occurs Example
Error Base class โ€” general errors new Error('Something went wrong')
TypeError Wrong type โ€” calling non-function, accessing null property null.name
ReferenceError Variable not defined console.log(undeclaredVar)
SyntaxError Invalid JavaScript syntax JSON.parse('bad json')
RangeError Value out of allowed range new Array(-1)
Custom Error Application-specific errors class ValidationError extends Error { }

Error Object Properties

Property Type Contains
err.message string Human-readable error description
err.name string Error type: ‘TypeError’, ‘RangeError’, etc.
err.stack string Stack trace โ€” file, line, column of each call
err instanceof TypeError boolean Check specific error type in catch block
Note: finally runs even if the try block contains a return statement. This makes it the right place for cleanup that must always happen โ€” closing database connections, releasing locks, hiding loading spinners. However, if finally itself contains a return, it overrides the try block’s return value, which is almost never what you want.
Tip: Always throw Error objects, never raw strings: throw new Error('message') not throw 'message'. Error objects have a .stack property with a full stack trace, which is invaluable for debugging. A thrown string gives you no context about where or why the error occurred. For domain-specific errors, extend Error with custom classes.
Warning: Do not use empty catch blocks: catch (err) { } โ€” they silently swallow errors and make bugs invisible. At minimum, console.error(err) inside every catch so you know something failed. In production, send errors to a logging service. Swallowed errors are one of the hardest categories of bugs to diagnose.

Basic Example

// โ”€โ”€ Basic try/catch/finally โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function divideNumbers(a, b) {
    try {
        if (typeof a !== 'number' || typeof b !== 'number') {
            throw new TypeError('Both arguments must be numbers');
        }
        if (b === 0) {
            throw new RangeError('Cannot divide by zero');
        }
        return a / b;
    } catch (err) {
        console.error(`[${err.name}] ${err.message}`);
        return null;
    } finally {
        console.log('divideNumbers() completed');  // always runs
    }
}

console.log(divideNumbers(10, 2));    //  5       + 'completed'
console.log(divideNumbers(10, 0));    // null     + '[RangeError]...' + 'completed'
console.log(divideNumbers('a', 2));   // null     + '[TypeError]...'  + 'completed'

// โ”€โ”€ Custom Error classes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class ValidationError extends Error {
    constructor(field, message) {
        super(message);
        this.name      = 'ValidationError';
        this.field     = field;
        this.timestamp = new Date().toISOString();
    }
}

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

// โ”€โ”€ instanceof to handle different error types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function processUserInput(data) {
    try {
        if (!data.email?.includes('@')) {
            throw new ValidationError('email', 'Must be a valid email address');
        }
        if (!data.age || data.age < 13) {
            throw new ValidationError('age', 'Must be 13 or older');
        }
        // Simulate network call
        if (data.forceError) {
            throw new NetworkError('Service unavailable', 503);
        }
        return { success: true, message: 'User processed successfully' };

    } catch (err) {
        if (err instanceof ValidationError) {
            // Validation errors are user's fault โ€” show friendly message
            return { success: false, field: err.field, message: err.message };
        }
        if (err instanceof NetworkError) {
            // Network errors are transient โ€” suggest retry
            return { success: false, message: `Server error ${err.statusCode} โ€” please try again` };
        }
        // Unknown error โ€” re-throw for outer error handler
        throw err;
    }
}

console.log(processUserInput({ email: 'alice@example.com', age: 25 }));
// { success: true, message: 'User processed successfully' }

console.log(processUserInput({ email: 'notanemail', age: 25 }));
// { success: false, field: 'email', message: 'Must be a valid email address' }

console.log(processUserInput({ email: 'b@b.com', age: 25, forceError: true }));
// { success: false, message: 'Server error 503 โ€” please try again' }

// โ”€โ”€ JSON.parse error handling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
function safeJsonParse(str, fallback = null) {
    try {
        return JSON.parse(str);
    } catch {
        return fallback;   // silently return fallback for invalid JSON
    }
}

console.log(safeJsonParse('{"name":"Bob"}'));   // { name: 'Bob' }
console.log(safeJsonParse('invalid json'));     // null
console.log(safeJsonParse('', []));            // []

How It Works

Step 1 โ€” throw Exits the Current Block Immediately

When throw executes, JavaScript stops running the current function and unwinds the call stack โ€” moving up through calling functions โ€” until it finds a catch block. If no catch is found, the error becomes an unhandled exception. Anything after the throw in the same block is never reached.

Step 2 โ€” catch Receives the Thrown Value

Whatever value you pass to throw โ€” an Error object, a string, a number โ€” becomes the err parameter in catch (err). Because any value can be thrown, always check err instanceof Error before accessing err.message if you cannot guarantee what was thrown.

Step 3 โ€” Custom Errors Enable instanceof Checks

By extending Error, custom error classes get the full Error API (message, stack) plus your own properties (field, statusCode). The instanceof check in catch lets you handle each error type differently โ€” show a form validation message for ValidationError, offer a retry for NetworkError, and re-throw anything unexpected.

Step 4 โ€” Re-throwing Unknown Errors

The pattern if (err instanceof KnownError) { handle } else { throw err; } is a best practice. Your catch block should only handle errors it knows about. Rethrowing unexpected errors lets an outer error handler or a global error listener deal with them โ€” rather than silently swallowing bugs you did not anticipate.

Step 5 โ€” finally for Guaranteed Cleanup

Database connections, file handles, loading spinners, and network request cancellation tokens all need to be cleaned up whether the operation succeeded or failed. Putting cleanup in finally guarantees it runs โ€” even if catch re-throws the error or try contains a return.

Real-World Example: API Client with Error Handling

// api-client.js

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

async function apiFetch(url, options = {}) {
    let response;

    try {
        response = await fetch(url, {
            headers: { 'Content-Type': 'application/json', ...options.headers },
            ...options,
        });

        const body = await response.json().catch(() => ({}));

        if (!response.ok) {
            throw new ApiError(
                body.message ?? `Request failed with status ${response.status}`,
                response.status,
                body
            );
        }

        return body;

    } catch (err) {
        if (err instanceof ApiError) {
            // Known HTTP error โ€” handle by status
            if (err.status === 401) {
                redirectToLogin();
                return null;
            }
            if (err.status === 429) {
                console.warn('Rate limited โ€” retry after delay');
            }
            throw err;  // re-throw for caller to handle
        }

        // Network error (fetch itself failed โ€” no internet, DNS failure)
        throw new NetworkError(`Network request failed: ${err.message}`, 0);

    } finally {
        // Could hide a loading spinner here
        // hideLoader();
    }
}

// Usage with async/await error handling
async function loadUserProfile(userId) {
    try {
        const user = await apiFetch(`/api/users/${userId}`);
        return user;
    } catch (err) {
        if (err instanceof ApiError && err.status === 404) {
            return null;  // user not found is not an error for the caller
        }
        throw err;  // everything else propagates up
    }
}

Common Mistakes

Mistake 1 โ€” Empty catch block silently swallows errors

โŒ Wrong โ€” errors disappear with no trace:

try {
    riskyOperation();
} catch (err) {
    // nothing here โ€” error is gone forever
}

โœ… Correct โ€” always at minimum log the error:

try {
    riskyOperation();
} catch (err) {
    console.error('riskyOperation failed:', err);
    // handle or rethrow
}

Mistake 2 โ€” Throwing a string instead of an Error object

โŒ Wrong โ€” string has no stack trace, no name, no useful debug info:

throw 'Something went wrong';
// catch (err) โ€” err is just a string, no err.stack available

โœ… Correct โ€” throw an Error object for full debug information:

throw new Error('Something went wrong');
// catch (err) โ€” err.message, err.stack, err.name all available

Mistake 3 โ€” Wrapping entire function in try/catch unnecessarily

โŒ Wrong โ€” catches errors from unrelated code too broadly:

try {
    validateInput(data);   // synchronous โ€” can't throw async errors anyway
    processData(data);
    saveToDatabase(data);  // only THIS needs try/catch
    updateUI(data);
} catch (err) { }

โœ… Correct โ€” wrap only the operation that can fail:

validateInput(data);
processData(data);
try {
    await saveToDatabase(data);
} catch (err) {
    console.error('Database save failed:', err);
    throw err;
}
updateUI(data);

▶ Try It Yourself

Quick Reference

Construct Example Notes
try/catch try { } catch (err) { } Wrap only the risky operation
finally } finally { cleanup(); } Always runs โ€” use for cleanup
throw throw new Error('msg') Always throw Error objects
Custom error class AppError extends Error { } Enables instanceof checks in catch
instanceof check if (err instanceof TypeError) Handle different errors differently
Re-throw catch (err) { if (!known) throw err; } Only handle what you understand
Safe parse try { JSON.parse(s) } catch { return null } Fallback for expected parse failures

🧠 Test Yourself

Which statement about the finally block is true?





โ–ถ Try It Yourself