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
- try: the risky part Keep it small. Everything inside is suspect, so a wide
trymakes it hard to tell what actually failed. - 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.
- 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: boomOrder 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
| Type | Thrown when | You throw it when |
|---|---|---|
Error | never automatically | nothing more specific fits |
TypeError | a value is the wrong type: null.x, calling a non-function | an argument is the wrong type or shape |
RangeError | new Array(-1), toFixed(101), stack overflow | a value is the right type but an impossible amount |
ReferenceError | you use a name that does not exist, or a TDZ binding | basically never by hand |
SyntaxError | the code or the JSON cannot be parsed | when you write a parser |
URIError | decodeURIComponent('%') | basically never by hand |
AggregateError | Promise.any when every promise rejects | you 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
catchprepares the return value, thenfinallyruns before the value actually leavesf, socleanupis logged first. The outerconsole.logprintscaughtsecond. - Which error does
null.namethrow? TypeError—nullexists as a value, so the name resolved fine. Reading a property of it is a type problem, so you get aTypeError. AReferenceErrormeans the name itself could not be resolved.- You are writing a low-level
fetchJsonhelper. 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
undefinedpushes 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,stackandinstanceof, 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 needstypeof err === 'string'special cases. Always throw anError.
Common mistakes
- Wrapping fifty lines in one
tryso you cannot tell which line failed. - Returning from
finally, which silently discards the real result or the pending error. - Empty
catchblocks, the most effective way to hide a bug from yourself. - Catching an error just to
console.logit and then continuing as if nothing happened.
Takeaways
throwis a second return path: it unwinds outward until something catches it.- Keep
tryblocks narrow so thecatchcan only be about one thing. finallyalways runs, and areturninside it overrides everything. Use it only for cleanup.- Catch only when you can recover, retry, add context or translate. Otherwise let it travel.
- Throw
Errorobjects so handlers getname,message,stackandinstanceof.