Back to blog

Effective Error Handling Patterns in JavaScript

read
Blog post image

Effective Error Handling Patterns in JavaScript

Error handling is often an afterthought in development, yet it's one of the most critical aspects of building robust and maintainable applications. In this comprehensive guide, I'll explore effective error handling patterns in JavaScript that will help you write more resilient code, improve debugging experiences, and create better user experiences.

Table of Contents

Introduction

Every JavaScript developer encounters errors—they're an inevitable part of the development process. However, how you handle these errors can make the difference between a resilient application and one that breaks in unexpected ways.

Effective error handling serves several crucial purposes:

  1. Prevents application crashes by gracefully handling failures
  2. Improves debugging efficiency by providing clear error information
  3. Enhances user experience through meaningful error messages
  4. Increases code reliability with predictable failure behavior
  5. Facilitates maintenance by isolating error-prone areas

In this article, we'll explore practical strategies for implementing robust error handling across different contexts in JavaScript applications.

Understanding JavaScript Errors

Before diving into patterns, it's essential to understand the error types available in JavaScript:

Built-in Error Types

// Error: Base object for all errors
const genericError = new Error("Something went wrong");

// TypeError: When an operation is performed on an incompatible type
const typeError = new TypeError("Expected a string, got a number");

// ReferenceError: When referring to a variable that doesn't exist
const referenceError = new ReferenceError("x is not defined");

// SyntaxError: When there's a syntax issue in code parsing
// const syntaxError = new SyntaxError('Unexpected token }');

// RangeError: When a value is outside the allowed range
const rangeError = new RangeError("Invalid array length");

// URIError: When URI handling functions are misused
const uriError = new URIError("Malformed URI sequence");

// EvalError: Legacy error type for eval() issues
const evalError = new EvalError("Eval execution issue");

Error Properties

Every JavaScript Error object includes these standard properties:

  • name: The error type (e.g., "Error", "TypeError")
  • message: The error description
  • stack: A stack trace showing where the error occurred
try {
  throw new Error("Something went wrong");
} catch (error) {
  console.log(error.name); // "Error"
  console.log(error.message); // "Something went wrong"
  console.log(error.stack); // Stack trace string
}

Common Error Handling Patterns

Let's explore the most common error handling patterns in JavaScript:

1. Try-Catch Blocks

The most basic pattern is the try-catch block, which attempts to execute code and catches any errors that occur:

function divideNumbers(a, b) {
  try {
    if (b === 0) {
      throw new Error("Division by zero is not allowed");
    }
    return a / b;
  } catch (error) {
    console.error("An error occurred:", error.message);
    return null; // Fallback value
  } finally {
    console.log("Division operation completed");
    // Finally block always executes, regardless of error
  }
}

console.log(divideNumbers(10, 2)); // 5
console.log(divideNumbers(10, 0)); // null

2. Return Error Objects

In some cases, especially in synchronous functions where you want to avoid exceptions, you can return error objects:

function parseJSON(jsonString) {
  try {
    return {
      success: true,
      data: JSON.parse(jsonString),
      error: null,
    };
  } catch (error) {
    return {
      success: false,
      data: null,
      error: {
        message: error.message,
        name: error.name,
      },
    };
  }
}

// Usage
const goodResult = parseJSON('{"name":"John"}');
const badResult = parseJSON("{name:John}"); // Invalid JSON

if (goodResult.success) {
  console.log(goodResult.data.name); // "John"
} else {
  console.error(goodResult.error.message);
}

3. Error-First Callbacks

In traditional Node.js code, the error-first callback pattern is widely used:

function readFileAsync(path, callback) {
  fs.readFile(path, "utf8", (err, data) => {
    if (err) {
      // Error occurred, pass it as the first argument
      callback(err, null);
      return;
    }

    // Success case, first argument is null (no error)
    callback(null, data);
  });
}

// Usage
readFileAsync("/path/to/file.txt", (error, data) => {
  if (error) {
    console.error("Failed to read file:", error.message);
    return;
  }

  console.log("File contents:", data);
});

Asynchronous Error Handling

Asynchronous operations introduce additional complexity to error handling:

1. Promises

Promises provide a more elegant way to handle asynchronous errors using .catch():

function fetchUserData(userId) {
  return fetch(`https://api.example.com/users/${userId}`)
    .then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP error! Status: ${response.status}`);
      }
      return response.json();
    })
    .catch((error) => {
      console.error("Fetching user data failed:", error.message);
      throw error; // Re-throw or handle appropriately
    });
}

// Usage
fetchUserData(123)
  .then((user) => console.log("User:", user))
  .catch((error) => console.error("Error in caller:", error.message));

2. Async/Await with Try-Catch

Async/await makes asynchronous error handling even more straightforward:

async function fetchUserData(userId) {
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);

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

    return await response.json();
  } catch (error) {
    console.error("Fetching user data failed:", error.message);
    throw error; // Re-throw or handle as needed
  }
}

// Usage
(async () => {
  try {
    const user = await fetchUserData(123);
    console.log("User:", user);
  } catch (error) {
    console.error("Failed to get user:", error.message);
    // Handle error appropriately
  }
})();

3. Promise.allSettled for Batch Operations

When dealing with multiple async operations where you want to handle each result separately:

async function fetchMultipleUsers(userIds) {
  const promises = userIds.map((id) =>
    fetch(`https://api.example.com/users/${id}`).then((response) =>
      response.json(),
    ),
  );

  const results = await Promise.allSettled(promises);

  return results.map((result, index) => {
    if (result.status === "fulfilled") {
      return { userId: userIds[index], data: result.value, error: null };
    } else {
      return { userId: userIds[index], data: null, error: result.reason };
    }
  });
}

// Usage
fetchMultipleUsers([1, 2, 3]).then((results) => {
  results.forEach((result) => {
    if (result.error) {
      console.error(`Failed to fetch user ${result.userId}:`, result.error);
    } else {
      console.log(`User ${result.userId} data:`, result.data);
    }
  });
});

Custom Error Classes

Creating custom error classes allows for more specific error handling:

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

    // Maintains proper stack trace on V8 engines
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, ValidationError);
    }
  }
}

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

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseError);
    }
  }
}

// Usage
function validateUser(user) {
  if (!user.email) {
    throw new ValidationError("Email is required", "email");
  }

  if (!user.email.includes("@")) {
    throw new ValidationError("Invalid email format", "email");
  }

  return true;
}

try {
  validateUser({ name: "John" });
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(
      `Validation failed for field "${error.field}":`,
      error.message,
    );
    // Handle validation errors specifically
  } else if (error instanceof DatabaseError) {
    console.error("Database operation failed:", error.message);
    // Handle database errors specifically
  } else {
    console.error("Unknown error:", error.message);
    // Handle other errors
  }
}

Global Error Handling

Browser Environment

In the browser, you can set up global error handlers:

// Handle uncaught exceptions
window.addEventListener("error", (event) => {
  console.error("Global error caught:", event.error.message);
  // Report to analytics/monitoring service
  reportErrorToService({
    message: event.error.message,
    stack: event.error.stack,
    url: window.location.href,
    timestamp: new Date(),
  });

  // Optionally show user-friendly message
  showErrorToUser("Something went wrong. Our team has been notified.");

  // Prevent the default error handling
  event.preventDefault();
});

// Handle unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  // Similar reporting logic as above

  event.preventDefault();
});

Node.js Environment

Similarly, in Node.js:

// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
  // Log to file or external service
  logErrorToService(error);

  // Gracefully shut down the process
  // It's often better to crash and restart than continue in an unknown state
  process.exit(1);
});

// Handle unhandled promise rejections
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled promise rejection:", reason);
  // Log to file or external service
  logErrorToService(reason);

  // In Node.js 15+, unhandled rejections will terminate the process
  // For earlier versions, you might want to:
  // process.exit(1);
});

Error Handling in React

React provides several mechanisms for error handling:

Error Boundaries

Error boundaries are React components that catch JavaScript errors in their child component tree:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render shows fallback UI
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // Log the error to an error reporting service
    console.error("Error caught by boundary:", error, errorInfo);
    reportErrorToService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // Render fallback UI
      return (
        <div className="error-container">
          <h2>Something went wrong</h2>
          {this.props.showDetails && (
            <details>
              <summary>Error details</summary>
              <p>{this.state.error && this.state.error.toString()}</p>
            </details>
          )}
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      );
    }

    // If no error, render children normally
    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <div className="app">
      <ErrorBoundary>
        <Header />
      </ErrorBoundary>

      <ErrorBoundary>
        <MainContent />
      </ErrorBoundary>

      <ErrorBoundary>
        <Footer />
      </ErrorBoundary>
    </div>
  );
}

Custom Hooks for API Errors

You can create custom hooks to standardize API error handling:

function useApiRequest(apiFunction) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const execute = useCallback(
    async (...args) => {
      try {
        setLoading(true);
        setError(null);
        const result = await apiFunction(...args);
        setData(result);
        return result;
      } catch (err) {
        setError(err);
        // You could categorize errors here based on status codes, etc.
        if (err.response && err.response.status === 401) {
          // Handle unauthorized errors
        }
        throw err; // Rethrow if needed
      } finally {
        setLoading(false);
      }
    },
    [apiFunction],
  );

  return { execute, data, loading, error };
}

// Usage in component
function UserProfile({ userId }) {
  const {
    execute: fetchUser,
    data: user,
    loading,
    error,
  } = useApiRequest(api.fetchUserById);

  useEffect(() => {
    fetchUser(userId).catch((err) => {
      // Any additional handling
    });
  }, [userId, fetchUser]);

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  if (!user) return null;

  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      {/* Other user details */}
    </div>
  );
}

Error Handling in Node.js

Node.js applications require robust error handling, especially for server applications:

Express.js Error Middleware

Express.js uses middleware for centralized error handling:

const express = require("express");
const app = express();

// Your routes
app.get("/users/:id", async (req, res, next) => {
  try {
    const userId = req.params.id;
    const user = await database.findUser(userId);

    if (!user) {
      // Create a custom error with status
      const error = new Error("User not found");
      error.statusCode = 404;
      throw error;
    }

    res.json(user);
  } catch (error) {
    // Pass the error to the error-handling middleware
    next(error);
  }
});

// Error handling middleware (must be defined last)
app.use((error, req, res, next) => {
  // Log error details
  console.error(`${req.method} ${req.path} error:`, error);

  // Set appropriate status code
  const statusCode = error.statusCode || 500;

  // Send error response
  res.status(statusCode).json({
    status: "error",
    message:
      process.env.NODE_ENV === "production"
        ? statusCode === 500
          ? "Internal server error"
          : error.message
        : error.message,
    stack: process.env.NODE_ENV === "production" ? undefined : error.stack,
  });
});

// 404 handler (for routes that don't exist)
app.use((req, res) => {
  res.status(404).json({
    status: "error",
    message: "Route not found",
  });
});

app.listen(3000);

Async Error Wrapper

A common pattern is to create a wrapper for async route handlers:

// Wrapper to catch async errors
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Usage
app.get(
  "/users/:id",
  asyncHandler(async (req, res) => {
    const userId = req.params.id;
    const user = await database.findUser(userId);

    if (!user) {
      const error = new Error("User not found");
      error.statusCode = 404;
      throw error; // This error will be caught by asyncHandler
    }

    res.json(user);
  }),
);

Logging and Monitoring

Proper error handling should include comprehensive logging:

// Simple logger example
const logger = {
  error: (message, error, context = {}) => {
    const timestamp = new Date().toISOString();
    const errorDetails = error
      ? {
          message: error.message,
          name: error.name,
          stack: error.stack,
          ...context,
        }
      : context;

    console.error(
      JSON.stringify({
        level: "ERROR",
        timestamp,
        message,
        ...errorDetails,
      }),
    );

    // In a real application, you'd send this to a logging service
    // logService.sendLog('error', message, errorDetails);
  },

  warn: (message, context = {}) => {
    // Similar implementation for warnings
  },

  info: (message, context = {}) => {
    // Similar implementation for info
  },
};

// Usage
try {
  throw new Error("Something failed");
} catch (error) {
  logger.error("Operation failed", error, {
    operation: "data-processing",
    userId: "123",
  });
}

Structured Error Logging

For production applications, structured logging is crucial:

function logError(error, request = {}) {
  const errorLog = {
    timestamp: new Date().toISOString(),
    errorName: error.name,
    message: error.message,
    stack: error.stack,

    // Add context from request if available
    userId: request.user?.id,
    path: request.path,
    method: request.method,
    query: request.query,

    // Additional context from custom errors
    ...(error.context || {}),
  };

  // In production, send to a logging service
  if (process.env.NODE_ENV === "production") {
    // Example services: Winston, Sentry, LogRocket, etc.
    logService.captureException(error, { extra: errorLog });
  } else {
    // In development, log to console
    console.error(JSON.stringify(errorLog, null, 2));
  }
}

Testing Error Scenarios

Testing error handling is often overlooked but crucial:

// Example using Jest
describe("User Service", () => {
  test("should throw ValidationError when email is missing", () => {
    // Arrange
    const invalidUser = { name: "John" };

    // Act & Assert
    expect(() => {
      validateUser(invalidUser);
    }).toThrow(ValidationError);
  });

  test("should include the field name in ValidationError", () => {
    // Arrange
    const invalidUser = { name: "John" };

    // Act
    try {
      validateUser(invalidUser);
      fail("Expected validation to fail");
    } catch (error) {
      // Assert
      expect(error instanceof ValidationError).toBe(true);
      expect(error.field).toBe("email");
    }
  });

  test("async function should reject with specific error", async () => {
    // Arrange
    const userId = "non-existent";

    // Act & Assert
    await expect(fetchUserById(userId)).rejects.toThrow("User not found");
  });
});

Best Practices

To conclude, here are some best practices for error handling in JavaScript:

  1. Be specific with error types: Use custom error classes for different error categories.

  2. Include relevant context: Add useful information to errors to aid debugging.

  3. Don't swallow errors: When catching errors, either handle them appropriately or re-throw them.

  4. Use async/await with try-catch: This provides the cleanest way to handle asynchronous errors.

  5. Implement global error handlers: Catch unhandled errors to prevent application crashes.

  6. Log errors properly: Include enough information for debugging without exposing sensitive data.

  7. Present user-friendly messages: Show technical details only in development, not to end users.

  8. Test error cases: Write specific tests for your error handling code.

  9. Use error boundaries in React: Compartmentalize your application to prevent full crashes.

  10. Consider error severity: Not all errors should be treated the same way—some might require immediate attention.

Conclusion

Effective error handling is a cornerstone of robust JavaScript applications. By implementing the patterns described in this article, you can build more resilient systems that gracefully handle failures, provide better debugging experiences, and maintain a positive user experience even when things go wrong.

Remember that good error handling isn't about avoiding errors—it's about dealing with them in a way that minimizes impact and maximizes learning. Over time, a comprehensive approach to error handling will not only improve your applications but also speed up development by making bugs easier to identify and fix.

What error handling patterns have you found most effective in your JavaScript applications? Share your experiences and insights in the comments below.

Happy coding!