Errors and try/catch

Mental model: A throw is a second return path. try/catch is how you decide which function is responsible for handling it.

Level: intermediate · about 14 minutes

A function has two ways out. It can return a value, or it can throw one. A throw stops the function immediately and keeps unwinding outward, function by function, until something catches it. If nothing does, the program (or in a browser, the current task) dies and the error lands in the console.

function parseAge(input) {
  const age = Number(input);
  if (!Number.isInteger(age)) throw new TypeError('age must be a whole number');
  return age;
}

try {
  console.log(parseAge('42'));      // → 42
  console.log(parseAge('forty'));   // throws here
  console.log('never reached');
} catch (err) {
  console.log(err.name, '|', err.message); // → TypeError | age must be a whole number
}

The second exit. Note that the line after the failing call never runs.

`err.name`
the error type, as a string: 'TypeError', 'RangeError', or whatever you set
`err.message`
the human readable text you passed to the constructor
`err.stack`
where it was created, innermost frame first (not in the standard, but universal)
`err.cause`
the error that led to this one, if you passed one (ES2022)

The three blocks

  1. try: the risky part Keep it small. Everything inside is suspect, so a wide try makes it hard to tell what actually failed.
  2. catch: what you do about it You get the thrown value. Since ES2019 you can drop the binding entirely when you do not need it.
  3. finally: what happens either way Runs on success, on throw, and even on an early return. It is where cleanup belongs.
function attempt(shouldFail) {
  try {
    if (shouldFail) throw new Error('boom');
    return 'ok';
  } catch (err) {
    return 'recovered from: ' + err.message;
  } finally {
    console.log('cleanup runs either way');
  }
}

console.log(attempt(false)); // → cleanup runs either way, then ok
console.log(attempt(true));  // → cleanup runs either way, then recovered from: boom

Order of operations. The return value is computed, then finally runs, then the value leaves.

The finally trap

function which() {
  try {
    return 'try';
  } catch {
    return 'catch';
  } finally {
    return 'finally';
  }
}
console.log(which());

A return (or a throw) inside finally replaces whatever the try or catch was about to do. Worse, it silently swallows a pending exception. Never return or throw from finally, only clean up.

Catch, add context, rethrow

Swallows the problem

function loadSettings(text) {
  try {
    return JSON.parse(text);
  } catch (err) {
    console.log('parse failed');
    // returns undefined, caller has
    // no idea anything went wrong
  }
}

Adds context, keeps the failure

function loadSettings(text) {
  try {
    return JSON.parse(text);
  } catch (err) {
    throw new Error('settings are not valid JSON', {
      cause: err,
    });
  }
}

Only catch an error if you can do something useful: recover, retry, add context, or translate it into a type your caller understands. Otherwise let it travel. A handler high up the stack that logs once beats twenty handlers that each log a little.

function riskyStep(kind) {
  if (kind === 'range') throw new RangeError('out of bounds');
  throw new TypeError('wrong shape');
}

function run(kind) {
  try {
    riskyStep(kind);
  } catch (err) {
    if (err instanceof RangeError) return 'clamped to the nearest valid value';
    throw err;                     // not ours, keep it moving
  }
}

console.log(run('range'));         // → clamped to the nearest valid value

try {
  run('type');
} catch (err) {
  console.log('escaped:', err.name); // → escaped: TypeError
}

Rethrowing selectively: handle what you know, pass on what you do not.

The built-in error types

TypeThrown whenYou throw it when
Errornever automaticallynothing more specific fits
TypeErrora value is the wrong type: null.x, calling a non-functionan argument is the wrong type or shape
RangeErrornew Array(-1), toFixed(101), stack overflowa value is the right type but an impossible amount
ReferenceErroryou use a name that does not exist, or a TDZ bindingbasically never by hand
SyntaxErrorthe code or the JSON cannot be parsedwhen you write a parser
URIErrordecodeURIComponent('%')basically never by hand
AggregateErrorPromise.any when every promise rejectsyou collected several failures at once
const attempts = [
  () => null.name,
  () => undefinedFunction(),
  () => new Array(-1),
  () => (12.34).toFixed(101),
  () => JSON.parse('{ oops }'),
  () => decodeURIComponent('%'),
];

for (const attempt of attempts) {
  try {
    attempt();
  } catch (err) {
    console.log(err.name.padEnd(15), err.message.slice(0, 46));
  }
}

Which type does the engine pick? Run it and read the pairs.

Try it yourself

Prove the order of execution

function trace(label, body) {
  const log = [];
  try {
    log.push('try:start');
    body();
    log.push('try:end');
  } catch (err) {
    log.push('catch:' + err.message);
  } finally {
    log.push('finally');
  }
  return label + ' → ' + log.join(' | ');
}

console.log(trace('happy', () => {}));
console.log(trace('sad', () => { throw new Error('boom'); }));

Add a throw inside finally and watch it eat the original error. Then remove the catch entirely and see who reports the failure.

Exercises

Parse untrusted JSON safely

Write safeParse(text, fallback = null). Return the parsed value when text is valid JSON. When JSON.parse throws, return fallback instead and let nothing escape. A falsy parsed value is still a success.

Always release the resource

Write withResource(resource, work). Call work(resource) and return its result. Whether work succeeds or throws, call resource.close() exactly once. If work throws, the original error must still reach the caller unchanged.

Check yourself

What does this print, and in what order?
'cleanup' then 'caught' — The catch prepares the return value, then finally runs before the value actually leaves f, so cleanup is logged first. The outer console.log prints caught second.
Which error does null.name throw?
TypeError — null exists as a value, so the name resolved fine. Reading a property of it is a type problem, so you get a TypeError. A ReferenceError means the name itself could not be resolved.
You are writing a low-level fetchJson helper. It hits an error it cannot recover from. What should it do?
Let it propagate, or rethrow it wrapped with context — A helper rarely has enough context to decide what recovery means. Returning undefined pushes the failure into a place where the stack trace is gone. Add context and rethrow, and let the caller (who knows whether this is fatal) decide.
Why is throw 'not found' a bad idea?
You lose name, stack and instanceof, so handlers have to guess — Throwing a string is legal and catchable, but the value carries no type, no message field and no stack. Every handler downstream then needs typeof err === 'string' special cases. Always throw an Error.

Common mistakes

  • Wrapping fifty lines in one try so you cannot tell which line failed.
  • Returning from finally, which silently discards the real result or the pending error.
  • Empty catch blocks, the most effective way to hide a bug from yourself.
  • Catching an error just to console.log it and then continuing as if nothing happened.

Takeaways

  • throw is a second return path: it unwinds outward until something catches it.
  • Keep try blocks narrow so the catch can only be about one thing.
  • finally always runs, and a return inside it overrides everything. Use it only for cleanup.
  • Catch only when you can recover, retry, add context or translate. Otherwise let it travel.
  • Throw Error objects so handlers get name, message, stack and instanceof.