Error Handling

A program can fail at runtime, and error handling is how we deal with that. We have not covered it in the earlier chapters, so we start here with the basics (try/catch/finally and the throw statement), and then we look at how error handling works with callbacks, Promises, and async/await.

try/catch/finally

The try block contains code that might throw. The catch block runs if an error is thrown. The finally block always runs, regardless of whether an error occurred.

try {
  const result = riskyOperation();
  console.log(result);
} catch (error) {
  console.error("Something went wrong:", error.message);
} finally {
  console.log("Cleanup complete");
}
  • catch receives the error object and can inspect its message and stack properties
  • finally is useful for cleanup: closing connections, clearing timers, resetting state

Throwing Errors with throw

Use throw to signal an error condition. Always throw Error objects (not strings or numbers) so you get a stack trace:

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

try {
  divide(10, 0);
} catch (error) {
  console.error(error.message); // "Division by zero"
}
// Always use Error objects
throw new Error("Something went wrong");  // Good
throw "Something went wrong";  // Avoid — no stack trace

Built-in Error Types and When They Occur

Type Typical cause
Error Generic error
TypeError Wrong type (e.g., calling a non-function)
ReferenceError Undefined variable
SyntaxError Invalid syntax
RangeError Value out of range
throw new TypeError("Expected a string, got number");
throw new RangeError("Index must be between 0 and 100");

Error Handling in Async Code

Callbacks: Error-First Convention

As covered in the Callbacks section, Node.js callbacks pass the error as the first argument:

import fs from "node:fs";

fs.readFile("config.json", "utf8", (error, data) => {
  if (error) {
    console.error("Read failed:", error.message);
    return;
  }
  console.log(data);
});

Promises: .catch()

A .catch() at the end of a chain handles rejections from any preceding .then():

fetchUser(1)
  .then((user) => fetchPosts(user.id))
  .then((posts) => console.log(posts))
  .catch((error) => {
    console.error("Error:", error.message);
  });

You can also recover from errors mid-chain by returning a fallback value from .catch():

fetchUser(1)
  .then((user) => fetchAvatar(user.id))
  .catch((error) => {
    console.error("Avatar failed, using default");
    return "/images/default-avatar.png";  // Recovery value
  })
  .then((avatarUrl) => {
    displayAvatar(avatarUrl); // Continues with the fallback
  });

Async/Await: try/catch

With async/await, use the same try/catch you would use for synchronous code:

async function loadUserData(id) {
  try {
    const user = await fetchUser(id);
    const posts = await fetchPosts(user.id);
    return { user, posts };
  } catch (error) {
    console.error("Failed to load:", error.message);
    throw error;
  }
}

Handling Partial Failures

When running multiple async operations where some may fail, Promise.allSettled lets you inspect each result individually instead of failing fast. See the Promise Combinators section for full examples and comparison with Promise.all.

Best Practices

  • Always use Error objects: They carry a stack trace, which makes debugging easier than it is with a thrown string or number

  • Do not discard errors silently: An empty catch block hides bugs and makes failures harder to diagnose. At minimum, log the error. If you cannot handle it, re-throw it:

    catch (error) {
      if (error instanceof ValidationError) {
        showFieldError(error.field, error.message);
      } else {
        throw error; // Let unexpected errors propagate
      }
    }
    
  • Use finally for cleanup: Ensure resources are released regardless of success or failure:

    const connection = await openDatabase();
    try {
      await connection.query("...");
    } finally {
      await connection.close();  // Always runs
    }
    
  • Always handle Promise rejections: In modern Node.js, unhandled rejections can terminate the process. In browsers, they trigger unhandledrejection events and console errors. Attach .catch() to Promise chains, or use try/catch with await.

  • Create custom error types for complex applications:

    class ValidationError extends Error {
      constructor(field, message) {
        super(message);
        this.name = "ValidationError";
        this.field = field;
      }
    }
    
    throw new ValidationError("email", "Invalid email format");