Skip to content

Error Handling

Error handling is a pivotal aspect of any program, as it enables you to address unexpected situations and ensures your program continues to run accurately. In this guide, you’ll learn about different error types in JavaScript, such as syntax errors and runtime errors, and how to manage them using try-catch statements and throw statements.

Proper error handling makes your applications more robust, easier to debug, and provides better user experiences by gracefully handling failures instead of crashing.

Basic Error Handling with try-catch

Just like in any other language, JavaScript uses try/catch/finally to manage errors. However, JavaScript has some unique characteristics you need to be aware of:

  • Always check for undefined and NaN
  • void results in undefined
  • undefined + 1 yields NaN
let o = {}; // equivalent to new Object();
o.toString(); // works fine

// If uncommented, the following line would throw an error.
// o.tostring(); // TypeError: o.tostring is not a function

try {
    o.tostring(); // This will throw an error
} catch (error) {
    console.log(error.message); // "o.tostring is not a function"
} finally {
    console.log("Always executes, irrespective of errors"); 
}

The try-catch-finally structure:

  • try block: Contains code that might throw an error
  • catch block: Handles the error if one occurs
  • finally block: Always executes, whether an error occurred or not

When is finally useful?

function readFile(filename) {
    let file = null;
    try {
        file = openFile(filename);
        return processFile(file);
    } catch (error) {
        console.error("Error processing file:", error.message);
        return null;
    } finally {
        // Cleanup always happens, even if we return in try or catch
        if (file) {
            closeFile(file);
        }
    }
}

The Error Object

Different implementations exist across JavaScript environments, but the standard Error object always has these properties:

  • message: A brief error message
  • name: The type of the error (e.g., “TypeError”, “ReferenceError”)
  • stack: A stack trace showing where the error occurred
try {
    o.tostring(); // Intentional error
} catch (error) {
    console.log(error.message);  // "o.tostring is not a function"
    console.log(error.name);     // "TypeError"
    console.log(error.stack);    // Full stack trace
}

Built-in Error Types

JavaScript has several built-in error types for different situations:

// ReferenceError - accessing undefined variable
try {
    console.log(nonExistentVariable);
} catch (error) {
    console.log(error.name); // "ReferenceError"
}

// TypeError - using wrong type
try {
    null.toString();
} catch (error) {
    console.log(error.name); // "TypeError"
}

// RangeError - number out of range
try {
    let arr = new Array(-1);
} catch (error) {
    console.log(error.name); // "RangeError"
}

// SyntaxError - invalid syntax
try {
    eval("function test() { invalid syntax }");
} catch (error) {
    console.log(error.name); // "SyntaxError"
}

// URIError - invalid URI functions usage
try {
    decodeURIComponent('%');
} catch (error) {
    console.log(error.name); // "URIError"
}

Using throw to Generate Errors

The throw statement is used to create custom errors. It’s a way to signal that an error has occurred while executing a function or a block of code. When a throw statement is encountered, the JavaScript runtime throws an exception. The execution of the current function will stop and control will be passed to the first catch block in the call stack. If no catch block exists among caller functions, the program will terminate.

Basic throw usage:

function add(a, b) {
    if (arguments.length !== 2) {
        throw new Error("Incorrect number of arguments");
    }
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw new Error("Arguments must be numbers");
    }
    return a + b;
}

try {
    console.log(add(1, 2));  // 3 - works fine
    console.log(add(1));     // Throws error
} catch (error) {
    console.log(error.message);  // "Incorrect number of arguments"
}

You can throw anything:

// Throwing an Error object (recommended)
throw new Error("Something went wrong");

// Throwing a string (not recommended)
throw "Error occurred";

// Throwing a number
throw 404;

// Throwing an object
throw { code: 404, message: "Not found" };

Always throw Error objects

While JavaScript allows you to throw any value, you should always throw instances of Error or its subclasses. This ensures:

  • Stack traces are captured
  • Error handling code can rely on consistent properties
  • Better debugging experience
// ❌ Bad
throw "Something went wrong";

// ✅ Good
throw new Error("Something went wrong");

Custom Error Classes

For more structured error handling, create custom error classes:

// Custom error class
class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = "ValidationError";
    }
}

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

// Usage
function validateAge(age) {
    if (typeof age !== 'number') {
        throw new ValidationError("Age must be a number");
    }
    if (age < 0 || age > 150) {
        throw new ValidationError("Age must be between 0 and 150");
    }
    return true;
}

function queryDatabase(sql) {
    // Simulate database error
    throw new DatabaseError("Connection failed", sql);
}

// Handling different error types
try {
    validateAge("invalid");
} catch (error) {
    if (error instanceof ValidationError) {
        console.log("Validation failed:", error.message);
    } else if (error instanceof DatabaseError) {
        console.log("Database error:", error.message);
        console.log("Query was:", error.query);
    } else {
        console.log("Unknown error:", error);
    }
}

Real-world example - API request handler:

class ApiError extends Error {
    constructor(message, statusCode, endpoint) {
        super(message);
        this.name = "ApiError";
        this.statusCode = statusCode;
        this.endpoint = endpoint;
    }
}

async function fetchUser(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
            throw new ApiError(
                `Failed to fetch user ${userId}`,
                response.status,
                `/api/users/${userId}`
            );
        }

        return await response.json();
    } catch (error) {
        if (error instanceof ApiError) {
            console.error(`API Error [${error.statusCode}]:`, error.message);
            console.error(`Endpoint:`, error.endpoint);
        } else {
            console.error("Network error:", error.message);
        }
        throw error; // Re-throw for caller to handle
    }
}

Error Propagation

When an error occurs in a function and it’s not caught there, it will bubble up through the call stack to the first catch block it encounters.

function f1() {
    f2();
}

function f2() {
    f3();
}

function f3() {
    throw new Error("Error occurred in f3");
}

try {
    f1();
} catch (error) {
    console.log(error.message); // "Error occurred in f3"
    console.log(error.stack);   // Shows full call stack: f3 -> f2 -> f1
}

Understanding the call stack:

function calculateTotal(items) {
    return items.reduce((sum, item) => sum + getPrice(item), 0);
}

function getPrice(item) {
    if (!item.price) {
        throw new Error(`Missing price for item: ${item.name}`);
    }
    return item.price;
}

let cart = [
    { name: "Book", price: 20 },
    { name: "Pen" }, // Missing price!
    { name: "Notebook", price: 15 }
];

try {
    let total = calculateTotal(cart);
} catch (error) {
    console.error(error.message); // "Missing price for item: Pen"
    // The error bubbled from getPrice -> reduce -> calculateTotal
}

Async Error Handling

Modern JavaScript uses async/await extensively, and error handling works slightly differently with asynchronous code.

Promises with .catch()

function fetchData(url) {
    return fetch(url)
        .then(response => {
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return response.json();
        })
        .catch(error => {
            console.error("Fetch error:", error.message);
            throw error; // Re-throw if you want caller to handle it
        });
}

// Usage
fetchData('https://api.example.com/data')
    .then(data => console.log(data))
    .catch(error => console.error("Failed:", error));

Async/Await with try-catch

async function fetchUserData(userId) {
    try {
        let response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        let user = await response.json();
        return user;
    } catch (error) {
        console.error("Error fetching user:", error.message);
        throw error;
    }
}

// Usage
async function displayUser(userId) {
    try {
        let user = await fetchUserData(userId);
        console.log("User:", user);
    } catch (error) {
        console.error("Could not display user:", error.message);
    }
}

Multiple async operations:

async function loadDashboard(userId) {
    try {
        // Run in parallel
        let [user, posts, notifications] = await Promise.all([
            fetchUser(userId),
            fetchPosts(userId),
            fetchNotifications(userId)
        ]);

        return { user, posts, notifications };
    } catch (error) {
        // If ANY request fails, we catch it here
        console.error("Dashboard loading failed:", error.message);
        throw error;
    }
}

// Handle individual failures differently
async function loadDashboardSafe(userId) {
    let [userResult, postsResult, notificationsResult] = await Promise.allSettled([
        fetchUser(userId),
        fetchPosts(userId),
        fetchNotifications(userId)
    ]);

    return {
        user: userResult.status === 'fulfilled' ? userResult.value : null,
        posts: postsResult.status === 'fulfilled' ? postsResult.value : [],
        notifications: notificationsResult.status === 'fulfilled' ? notificationsResult.value : []
    };
}

Error Handling Best Practices

1. Don’t Silently Catch Errors

// ❌ Bad - swallowing errors
try {
    riskyOperation();
} catch (error) {
    // Empty catch - error is lost!
}

// ✅ Good - at least log the error
try {
    riskyOperation();
} catch (error) {
    console.error("Operation failed:", error);
    // Maybe show user-friendly message
}

// ✅ Better - handle specific errors, re-throw others
try {
    riskyOperation();
} catch (error) {
    if (error instanceof ValidationError) {
        showUserError(error.message);
    } else {
        // Log and re-throw unexpected errors
        console.error("Unexpected error:", error);
        throw error;
    }
}

2. Use Specific Error Types

// ❌ Bad - generic errors
function divide(a, b) {
    if (b === 0) {
        throw new Error("Error");
    }
    return a / b;
}

// ✅ Good - specific, descriptive errors
function divide(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw new TypeError("Arguments must be numbers");
    }
    if (b === 0) {
        throw new RangeError("Cannot divide by zero");
    }
    return a / b;
}

3. Provide Context in Error Messages

// ❌ Bad - vague error message
function getUser(id) {
    if (!id) {
        throw new Error("Invalid input");
    }
    // ...
}

// ✅ Good - specific, helpful error message
function getUser(id) {
    if (!id) {
        throw new Error(`User ID is required. Received: ${id}`);
    }
    if (typeof id !== 'number') {
        throw new TypeError(`User ID must be a number. Received type: ${typeof id}`);
    }
    // ...
}

4. Clean Up Resources in finally

function processFile(filename) {
    let fileHandle = null;

    try {
        fileHandle = openFile(filename);
        let data = readFile(fileHandle);
        return processData(data);
    } catch (error) {
        console.error(`Error processing ${filename}:`, error.message);
        throw error;
    } finally {
        // Always close the file, even if errors occurred
        if (fileHandle) {
            closeFile(fileHandle);
        }
    }
}

5. Use Error Boundaries (in UI frameworks)

For React and similar frameworks, use error boundaries to catch rendering errors:

// React Error Boundary example
class ErrorBoundary extends React.Component {
    constructor(props) {
        super(props);
        this.state = { hasError: false };
    }

    static getDerivedStateFromError(error) {
        return { hasError: true };
    }

    componentDidCatch(error, errorInfo) {
        console.error("Component error:", error, errorInfo);
        // Log to error reporting service
        logErrorToService(error, errorInfo);
    }

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

Logging

Proper logging is essential for debugging and monitoring applications in production. Use appropriate logging levels and services.

Console Logging Levels

// Different log levels for different purposes
console.log("General information");    // General output
console.info("Informational message"); // FYI messages
console.warn("Warning message");       // Warning - something unexpected
console.error("Error message");        // Error - something failed
console.debug("Debug message");        // Detailed debugging info

// Grouping related logs
console.group("User Login Process");
console.log("Validating credentials...");
console.log("Fetching user data...");
console.log("Creating session...");
console.groupEnd();

// Timing operations
console.time("Database Query");
// ... perform query ...
console.timeEnd("Database Query"); // "Database Query: 45.123ms"

Professional Logging Frameworks

Employ robust logging frameworks for production applications:

Modern logging services:

  • Sentry - Error tracking and performance monitoring
  • LogRocket - Session replay with error tracking
  • Datadog - Full-stack monitoring
  • New Relic - Application performance monitoring
  • Papertrail - Log aggregation
  • Loggly - Cloud-based log management

Client-side error tracking:

// Example with Sentry
import * as Sentry from "@sentry/browser";

Sentry.init({
    dsn: "your-sentry-dsn",
    environment: "production",
    beforeSend(event, hint) {
        // Filter out sensitive information
        if (event.user) {
            delete event.user.email;
        }
        return event;
    }
});

// Errors are automatically captured
try {
    riskyOperation();
} catch (error) {
    // Manually capture with additional context
    Sentry.captureException(error, {
        tags: {
            section: "checkout",
        },
        extra: {
            userId: currentUser.id,
        }
    });
}

Classic image beacon for logging:

function log(app, text) {
    let img = new Image();
    img.src = `https://log.example.com?app=${app}&text=${encodeURIComponent(text)}`;
}

// Usage
try {
    processCheckout();
} catch (error) {
    log("shop", `Checkout error: ${error.message}`);
}

Logging sensitive information

Never log sensitive information such as:

  • Passwords or authentication tokens
  • Credit card numbers
  • Personal identification numbers
  • API keys or secrets
  • Private user data (emails, addresses, phone numbers)

Always sanitize data before logging in production.


Assignment(s)


Testing

Testing is crucial for ensuring your error handling works correctly. Numerous test frameworks and tools can assist you:

Modern Testing Frameworks

Unit Testing:

  • Jest - Popular, zero-config testing framework
  • Vitest - Fast, Vite-native unit test framework
  • Mocha - Flexible testing framework
  • Jasmine - Behavior-driven testing

End-to-End Testing:

  • Playwright - Modern, cross-browser automation (recommended)
  • Cypress - Developer-friendly E2E testing
  • Puppeteer - Headless Chrome automation
  • Selenium - Cross-browser testing (older but stable)

Testing Libraries:

CI/CD Integration:

Testing Error Handling

// Example with Jest
describe('User validation', () => {
    test('should throw ValidationError for invalid age', () => {
        expect(() => {
            validateAge("invalid");
        }).toThrow(ValidationError);

        expect(() => {
            validateAge(-5);
        }).toThrow("Age must be between 0 and 150");
    });

    test('should handle API errors gracefully', async () => {
        // Mock fetch to simulate error
        global.fetch = jest.fn(() =>
            Promise.reject(new Error("Network error"))
        );

        await expect(fetchUser(123)).rejects.toThrow("Network error");
    });
});

// Example with Playwright for E2E testing
test('should show error message when login fails', async ({ page }) => {
    await page.goto('https://example.com/login');
    await page.fill('#username', 'invalid');
    await page.fill('#password', 'wrong');
    await page.click('#login-button');

    // Verify error message is displayed
    await expect(page.locator('.error-message')).toContainText('Invalid credentials');
});

Summary

Effective error handling is essential for building robust, maintainable JavaScript applications. It helps you catch problems early, debug issues faster, and provide better user experiences.

Key Takeaways

Basic Error Handling:

  • Use try-catch-finally for synchronous error handling
  • try contains code that might throw errors
  • catch handles errors that occur
  • finally always executes for cleanup
  • Use console.error() for error logging (not console.log())

Error Object:

  • Has message, name, and stack properties
  • Built-in types: Error, TypeError, ReferenceError, RangeError, SyntaxError, URIError
  • Always use Error objects (not strings or primitives)

Throwing Errors:

  • Use throw new Error("message") to create errors
  • Always throw Error instances (not strings or other types)
  • Create custom error classes for specific error types
  • Provide descriptive, contextual error messages

Error Propagation:

  • Errors bubble up the call stack until caught
  • Stack traces show the full call path
  • Use specific catch blocks for different error types
  • Re-throw errors when you can’t handle them completely

Async Error Handling:

  • Use .catch() with Promises
  • Use try-catch with async/await
  • Handle errors in Promise.all() and Promise.allSettled()
  • Async errors won’t be caught by synchronous try-catch

Best Practices:

  • Never silently swallow errors
  • Use specific error types and messages
  • Clean up resources in finally blocks
  • Don’t log sensitive information
  • Use custom error classes for structure
  • Re-throw errors you can’t handle
  • Test your error handling code

Logging:

  • Use appropriate console methods (error, warn, info, debug)
  • Use professional logging services for production (Sentry, LogRocket)
  • Never log passwords, tokens, or sensitive data
  • Include context in log messages

Testing:

  • Test both success and error paths
  • Use modern frameworks (Jest, Vitest, Playwright)
  • Mock external dependencies to test error scenarios
  • Verify error messages and types in tests
  • Use CI/CD for automated testing

Proper error handling transforms your applications from fragile scripts into robust, production-ready software. Always handle errors thoughtfully, log them appropriately, and test your error handling code as thoroughly as your success paths.