Lesson 26 of 30

Error Handling

Errors Are Not a Failure of Your Skill

Something in your program will go wrong that is not your fault. The network drops, a file is missing, a server returns nonsense, a user types something nobody anticipated. Error handling is how you decide what happens next, and it is the whole difference between a page that shows a helpful message and one that simply stops working with no explanation.

JavaScript has three distinct kinds of problem and it is worth separating them. A syntax error means the code could not be parsed at all, so nothing in that file runs. A runtime error — an exception — happens while the program is running and stops the current call chain unless something catches it. A logic error is code that runs perfectly and produces the wrong answer, which is the subject of the next lesson because no amount of try/catch will help you with it.

Only runtime errors can be caught. When one is thrown and nothing catches it, JavaScript walks back up the call stack looking for a handler; if it finds none, the error reaches the top and is reported in the console. In a browser the rest of the page usually keeps working, which is why a broken feature can sit unnoticed for weeks. In Node the process typically exits.

The habit worth building is to decide deliberately at each level: can this code do something genuinely useful about a failure here? If it can, catch it. If it cannot, let the error travel to someone who can. A catch that logs a message and carries on is frequently worse than no catch at all, because it turns a loud failure into a silent one and lets wrong data travel further into your program.

Example
// A syntax error - nothing in the file runs at all
// const x = ;

// A runtime error - thrown while running
function readCity(user) {
  return user.address.city;
}
// readCity({});   // TypeError: Cannot read properties of undefined

// A logic error - runs perfectly, and the answer is wrong
function average(marks) {
  return marks.reduce((a, b) => a + b, 0) / marks.length - 1;   // stray - 1
}
console.log(average([80, 90]));   // 84, and nothing anywhere complains
Notes
  • The three kinds fail differently and are found differently. A syntax error is found by your editor, a runtime error by the console, and a logic error only by testing what your code actually produces against what it should have produced.

try, catch and finally

try holds code that might throw. catch receives the error if it does. finally runs either way — and runs even when the try block contains a return.

Keep the try block small. Wrapping an entire function in one is tempting and makes the catch nearly useless: you know something failed but not what, and a mistake in your own recovery code ends up being caught by the very handler that was meant to recover from something else. Wrap the operation that can realistically fail, and nothing beyond it.

catch takes the error as a parameter, and since ES2019 you may leave the parameter out entirely when you do not need it: catch { ... }. That is reasonable when the response to any failure is identical. It is not reasonable when you would have wanted the error in a log, because once discarded it is gone.

finally is for cleanup that must happen on every path: hiding a spinner, closing a connection, re-enabling a button. It runs before the function actually returns, even when both try and catch contain a return statement. What you must not do is put a return inside finally itself — it overrides whatever the other blocks were returning, including an error on its way out, and the resulting behaviour is genuinely hard to read.

Example
function loadSettings(raw) {
  console.log('spinner shown');

  try {
    return JSON.parse(raw);      // the only line that can realistically throw
  } catch (err) {
    console.warn('Bad settings, using defaults:', err.message);
    return { theme: 'light' };
  } finally {
    console.log('spinner hidden');   // runs even though both branches return
  }
}

console.log(loadSettings('{"theme":"dark"}'));   // { theme: 'dark' }
console.log(loadSettings('not json'));           // { theme: 'light' }

// Optional catch binding, when the error itself is not needed
try {
  JSON.parse('nope');
} catch {
  console.log('could not parse');
}

// Do NOT return from finally - it swallows everything else
function confusing() {
  try {
    throw new Error('boom');
  } finally {
    return 'this wins, and the error simply disappears';
  }
}
console.log(confusing());
Notes
  • finally is the natural home for anything you would otherwise have to write twice — once in try and once in catch. Forgetting the catch copy is exactly how a button stays disabled forever after one failed request.

throw, and the Error Object

You can throw as well as catch. throw stops the current function immediately and hands control to the nearest enclosing catch, wherever that happens to be up the call stack — possibly several functions away, possibly nowhere.

Throw an Error object, never a string. throw 'something failed' is legal and gives up everything useful: a real Error carries a name, a message and a stack showing exactly where it came from, and it works with instanceof. Code catching a bare string can do none of that, and every handler in your codebase then has to guess what it received.

The built-in error types tell you what kind of problem occurred, and recognising them on sight saves real time. TypeError means a value was used in a way its type does not support — including the ubiquitous "cannot read properties of undefined". ReferenceError means a name that does not exist, usually a typo or the temporal dead zone from Lesson 2. SyntaxError means invalid code or invalid JSON. RangeError means a number outside an allowed range, such as an impossible array length.

When you throw, write a message that will help whoever reads the log at three in the morning — quite possibly you. Include both what was being attempted and the value that caused the problem. 'Invalid marks' is far less useful than a message naming the roll number and the offending value. Since ES2022 you can also attach the original error using the cause option, which preserves the chain when you wrap and re-throw.

Example
function setMarks(roll, value) {
  const marks = Number(value);

  if (Number.isNaN(marks)) {
    throw new Error(`Invalid marks for roll ${roll}: "${value}"`);
  }
  if (marks < 0 || marks > 100) {
    throw new RangeError(`Marks out of range for roll ${roll}: ${marks}`);
  }
  return marks;
}

try {
  setMarks('CS21-047', 'abc');
} catch (err) {
  console.log(err.name);               // 'Error'
  console.log(err.message);            // 'Invalid marks for roll CS21-047: "abc"'
  console.log(err instanceof Error);   // true
  // console.log(err.stack);           // exactly where it came from
}

// Keep the original error when you wrap and re-throw
try {
  try {
    JSON.parse('nope');
  } catch (err) {
    throw new Error('Could not read saved settings', { cause: err });
  }
} catch (err) {
  console.log(err.message);       // 'Could not read saved settings'
  console.log(err.cause.name);    // 'SyntaxError'
}
Notes
  • Read a stack trace from the top downwards. The first line is where the error was thrown; the lines beneath it are who called that, and who called them. The first line that belongs to your code is almost always where to start looking.

Custom Errors, and Deciding What to Catch

Once an application has more than one kind of failure, you need to tell them apart. Extending Error gives you a type you can check with instanceof, and lets you attach whatever extra information the handler will need — which field failed, which HTTP status came back.

A validation failure and a network failure deserve completely different responses: one puts a message next to a form field, the other offers a retry. Catching both in one block and reading the message text to decide between them is fragile, because the message is written for humans and will be reworded. The type is what code should branch on.

Set this.name in the constructor so that logs and console output read correctly, and call super(message) first so the standard Error behaviour — including the stack — still works.

The harder judgement is what to catch at all. Catch when you can genuinely recover: supply a default, retry, or tell the user something actionable. Do not catch a programming mistake such as calling a method on undefined. That should surface loudly during development, because catching it only means the wrong data travels further before something else breaks somewhere less obvious. And never write an empty catch block — if you truly do not care, at least write a comment saying why.

Example
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;        // extra information the handler can use
  }
}

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

function handle(err) {
  if (err instanceof ValidationError) {
    console.log(`Show a message beside the ${err.field} field: ${err.message}`);
  } else if (err instanceof NetworkError && err.status >= 500) {
    console.log('Server problem - offer a retry');
  } else {
    throw err;                 // not ours to handle - let it keep travelling
  }
}

try {
  throw new ValidationError('email', 'Enter a valid email address');
} catch (err) {
  handle(err);
}

try {
  throw new NetworkError('Gateway timeout', 504);
} catch (err) {
  handle(err);
}
Notes
  • Re-throwing what you cannot handle is as important as catching what you can. A handler that swallows everything, including errors it does not understand, is how a bug ends up being reported by a user rather than by your own monitoring.

Asynchronous Errors

A try/catch only covers the code physically inside it, and asynchronous callbacks run later — long after that block has finished. That single fact explains almost all of the confusion about error handling in asynchronous code.

So a try wrapped around a setTimeout call catches nothing that happens inside the callback. The try block completed the instant the timer was scheduled. The callback throws much later, into an empty stack, and goes straight to the global handler. The fix is a try/catch inside the callback, where the risky code actually lives.

For promises the rules are the ones from Lesson 21: a rejection is caught by .catch, or by a try/catch placed around an await. Mixing the two styles is where the mistakes creep in — a try around a promise-returning call with no await catches nothing at all, because the function returned a promise perfectly successfully and only rejected afterwards.

Two failure modes are worth watching for specifically. An async function called without await and without .catch produces an unhandled rejection that nobody sees. And an await inside a forEach callback is unprotected by any surrounding try, for exactly the same reason as the setTimeout case — the callback runs on its own, later.

Example
// The try block finished before the callback ever ran
try {
  setTimeout(() => {
    throw new Error('thrown later, into an empty stack');
  }, 0);
} catch (err) {
  console.log('never reached');
}

// The fix: catch inside the callback
setTimeout(() => {
  try {
    throw new Error('handled properly');
  } catch (err) {
    console.log('caught:', err.message);
  }
}, 0);

// Promises: put the await inside the try
async function right() {
  try {
    const data = await Promise.reject(new Error('network down'));
    return data;
  } catch (err) {
    console.log('caught:', err.message);
  }
}
right();

// Wrong: no await, so the try sees a promise returned successfully
async function wrong() {
  try {
    Promise.reject(new Error('lost'));   // becomes an unhandled rejection
  } catch (err) {
    console.log('never reached');
  }
}
wrong().catch(() => {});
Notes
  • The rule in one sentence: a try/catch protects the stack it is currently on, and every asynchronous callback starts a new one. If the risky line will run later, the handler has to be there with it.

Global Handlers, and What the User Should See

However careful you are, something will escape. Two global handlers catch what is left, and it is important to be clear that their job is reporting rather than recovery.

window.addEventListener('error', ...) receives uncaught exceptions, with the message, the file and the line number. window.addEventListener('unhandledrejection', ...) receives promise rejections that nobody handled, with the reason. Together these two are how every error-monitoring service works, and adding them to a real project is a handful of lines that will tell you about failures your users would never have reported.

Do not treat them as a substitute for local handling. They run after the operation has already failed, with no knowledge of what was being attempted or what the user was in the middle of, so all they can realistically do is record the failure and perhaps show a generic message.

Finally, what the user sees. An error message should say what happened in plain language and what they can do about it. It should never show a stack trace or a raw exception message — that is for your logs, and to a user it reads as a broken site. And when something has failed, the interface must return to a usable state: the spinner stops, the button becomes clickable again, the form still holds what they typed. A page that fails and then freezes is a considerably worse experience than one that fails and says so.

Example
window.addEventListener('error', (event) => {
  console.log('Uncaught:', event.message, 'at', event.filename, event.lineno);
  // report(event.error) to your monitoring service here
});

window.addEventListener('unhandledrejection', (event) => {
  console.log('Unhandled rejection:', event.reason);
  event.preventDefault();      // suppresses the default console warning
});

// A user-facing failure, handled properly
async function saveForm(form, button, status) {
  button.disabled = true;
  status.textContent = 'Saving...';

  try {
    // await api.save(new FormData(form));
    status.textContent = 'Saved';
  } catch (err) {
    console.error(err);        // the full detail, for you
    status.textContent =
      'Could not save. Please check your connection and try again.';
  } finally {
    button.disabled = false;   // the page is usable again on either path
  }
}
  • try small, catch specific, finally for cleanup
  • throw new Error(...), never throw 'a string'
  • Put the operation and the offending value into every error message
  • Extend Error so handlers branch on instanceof, not on message text
  • Never write an empty catch block
  • Do not catch programming mistakes — let them surface during development
  • A try does not cover a callback that runs later
  • Put the await inside the try, or the rejection escapes it
  • Global handlers report failures; they do not recover from them
Notes
  • The user's message and your log are two different things written for two different readers. Show the user what to do next; log everything you would need to reproduce the problem, including the values involved.
Ask AI