Skip to content
elephantoo

Error handling

Lesson 19 of 34 14 min read

try/catch/finally, throwing errors, custom error classes, error.cause and defensive code.


Things go wrong in every real program: a user types letters into a number field, a file is missing, a server returns garbage. Error handling is how your code notices those problems, reacts sensibly, and tells someone what happened — instead of crashing with a cryptic message or, worse, silently producing wrong results.

What an error looks like#

When JavaScript hits a problem it can't continue from, it throws an error. If nothing catches it, the program stops and the error is printed:

JavaScript
const user = null;
console.log("Before");
console.log(user.name); // 💥 TypeError
console.log("After");   // never runs
Output
Before
file:///home/you/demo.mjs:3
console.log(user.name); // 💥 TypeError
                 ^

TypeError: Cannot read properties of null (reading 'name')
    at file:///home/you/demo.mjs:3:18

The output tells you the error type (TypeError), the message, and a stack trace showing where it happened. Learn to read the first line of the stack trace — it usually points straight at the bug.

The built-in error types you'll meet most often:

TypeTypical cause
TypeErrorUsing a value the wrong way: calling a non-function, reading a property of null/undefined
ReferenceErrorUsing a variable that doesn't exist (or is in its temporal dead zone)
RangeErrorA number outside the allowed range, e.g. new Array(-1)
SyntaxErrorInvalid code, or invalid text passed to JSON.parse

try … catch#

Wrap code that might fail in try. If it throws, execution jumps to catch, which receives the error:

JavaScript
const input = '{"name": "Ada", "age": 36'; // missing closing brace

try {
  const data = JSON.parse(input);
  console.log("Parsed:", data.name);
} catch (err) {
  console.log("Could not parse JSON");
  console.log(err.name);                  // SyntaxError
  console.log(err instanceof SyntaxError); // true
}

console.log("The program keeps running");
Output
Could not parse JSON
SyntaxError
true
The program keeps running

Every error object has three useful properties:

  • err.name — the type, like "SyntaxError"
  • err.message — the human-readable description
  • err.stack — the stack trace as a string (non-standard but available in all engines)

If you don't need the error object, you can leave out the binding (optional catch binding):

JavaScript
function isValidJSON(text) {
  try {
    JSON.parse(text);
    return true;
  } catch {
    return false;
  }
}

console.log(isValidJSON('{"ok": true}')); // true
console.log(isValidJSON("{ok: true}"));   // false

finally: clean up no matter what#

A finally block runs after try/catch whatever happens — success, error, or even an early return:

JavaScript
function loadReport(shouldFail) {
  console.log("Show spinner");
  try {
    if (shouldFail) throw new Error("Server unavailable");
    return "Report data";
  } catch (err) {
    return `Error: ${err.message}`;
  } finally {
    console.log("Hide spinner"); // always runs
  }
}

console.log(loadReport(false));
console.log(loadReport(true));
Output
Show spinner
Hide spinner
Report data
Show spinner
Hide spinner
Error: Server unavailable

Notice that "Hide spinner" prints before the returned value is logged: finally runs before the function actually hands back its result. Use finally for clean-up: hiding loaders, closing connections, re-enabling buttons.

Don't return from inside finally. It silently overrides any value or error from try/catch, which hides bugs.

Throwing your own errors#

Use throw when your function can't do its job. Always throw an Error object (or a subclass), not a string:

JavaScript
function calculateTotal(price, quantity) {
  if (typeof price !== "number" || Number.isNaN(price)) {
    throw new TypeError(`price must be a number, got ${typeof price}`);
  }
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new RangeError(`quantity must be a whole number ≥ 1, got ${quantity}`);
  }
  return price * quantity;
}

console.log(calculateTotal(250, 2)); // 500

try {
  calculateTotal(250, 0);
} catch (err) {
  console.log(`${err.name}: ${err.message}`);
}
Output
500
RangeError: quantity must be a whole number ≥ 1, got 0

Why not throw "something broke"? You can throw any value, but a string has no name, no stack and can't be checked with instanceof. Every serious codebase throws Error objects.

Custom error classes#

For your own kinds of failure, extend Error. This lets callers tell different problems apart:

JavaScript
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError"; // so logs show the right type
    this.field = field;            // extra data for the caller
  }
}

class NotFoundError extends Error {
  name = "NotFoundError"; // a class field works too
}

const users = new Map([[1, { id: 1, email: "ada@example.com" }]]);

function updateEmail(id, email) {
  if (!email.includes("@")) {
    throw new ValidationError("email", `"${email}" is not a valid email`);
  }
  const user = users.get(id);
  if (!user) throw new NotFoundError(`No user with id ${id}`);
  user.email = email;
  return user;
}

for (const [id, email] of [[1, "ada@newmail.com"], [1, "nope"], [7, "x@y.z"]]) {
  try {
    const user = updateEmail(id, email);
    console.log("Updated:", user.email);
  } catch (err) {
    if (err instanceof ValidationError) {
      console.log(`Fix the ${err.field} field: ${err.message}`);
    } else if (err instanceof NotFoundError) {
      console.log(`404 – ${err.message}`);
    } else {
      throw err; // something unexpected: don't swallow it
    }
  }
}
Output
Updated: ada@newmail.com
Fix the email field: "nope" is not a valid email
404 – No user with id 7

The final else { throw err; } is important: only catch what you know how to handle, and rethrow the rest so real bugs aren't hidden.

Wrapping errors with cause#

Often a low-level error (like a SyntaxError from JSON.parse) isn't meaningful to the caller. Throw a clearer error and attach the original as its cause (ES2022):

JavaScript
function loadSettings(text) {
  try {
    return JSON.parse(text);
  } catch (err) {
    throw new Error("Could not load settings file", { cause: err });
  }
}

try {
  loadSettings("{ theme: dark }");
} catch (err) {
  console.log(err.message);
  console.log("Caused by:", err.cause.name);
}
Output
Could not load settings file
Caused by: SyntaxError

You get a friendly message for the user and the technical detail for debugging. Node and browser DevTools also print the cause chain when an error is logged.

try/catch only catches synchronous errors#

try/catch only sees errors thrown while the try block is running. A callback that runs later is outside it:

JavaScript
try {
  setTimeout(() => {
    throw new Error("Too late!"); // NOT caught below – crashes the program
  }, 0);
} catch (err) {
  console.log("You will never see this");
}

The try block finished long before the timer fired. To handle errors in asynchronous code you put the try/catch inside the callback, or use promises with .catch() and async/await with try/catch — all covered in the asynchronous module.

Global safety nets#

For errors nobody catches, you can register a last-resort handler — useful for logging to a monitoring service, not for normal control flow:

JavaScript
// In the browser
window.addEventListener("error", (event) => {
  console.log("Uncaught:", event.message);
});
window.addEventListener("unhandledrejection", (event) => {
  console.log("Unhandled promise rejection:", event.reason);
});

In Node the equivalents are process.on("uncaughtException", …) and process.on("unhandledRejection", …). After an uncaught exception, log the error and exit: the program may be in a broken state.

Defensive code: avoid errors in the first place#

Not every problem needs try/catch. Often it's clearer to check first:

JavaScript
function getCity(user) {
  // Optional chaining + nullish coalescing instead of crashing on null
  return user?.address?.city ?? "Unknown";
}

function average(numbers) {
  // Guard clauses: handle bad input up front and return early
  if (!Array.isArray(numbers)) throw new TypeError("Expected an array");
  if (numbers.length === 0) return 0;
  return numbers.reduce((sum, n) => sum + n, 0) / numbers.length;
}

console.log(getCity({ address: { city: "Pune" } })); // Pune
console.log(getCity(null));                          // Unknown
console.log(average([4, 8, 9]));                     // 7
console.log(average([]));                            // 0

A useful rule of thumb:

  • Expected situations (empty list, missing optional field) → handle with normal code.
  • Invalid input or broken contracts → throw a descriptive error, as early as possible ("fail fast").
  • Operations that can fail for outside reasons (parsing user data, network, storage) → try/catch where you can actually recover or show a message.

Common mistakes#

  • Swallowing errors: catch (err) {} with nothing inside hides bugs forever. At minimum log it, and usually rethrow.
  • Throwing strings instead of Error objects — you lose the stack trace.
  • Wrapping everything in one giant try. Keep try blocks small, around the code that can actually fail, so you know what went wrong.
  • Forgetting this.name in custom error classes, so logs say Error instead of ValidationError.
  • Showing raw error messages to users. err.message is for developers; show users a friendly, actionable message.
  • Expecting try/catch to catch errors inside callbacks that run later.

What's next#

Your programs are getting bigger. Next, learn to split code into separate files with modules: import and export.

Check your understanding

Quick quiz

0/3 answered
  1. 1.When does the code in a finally block run?

  2. 2.Which is the best way to signal that a function received invalid input?

  3. 3.What is error.cause for?

Finished reading?

Mark this lesson complete to track your progress.