Error Handling
Prerequisites: this chapter assumes you know functions, control flow, and promises/async-await as covered earlier in the spine.
The model
JavaScript errors are thrown values. throw accepts any value, but only Error instances (or subclasses) produce a stack trace, so always throw Error subclasses. try/catch/finally is the only recovery mechanism; there is no checked exceptions concept, so type signatures and documentation carry the burden of signaling failure.
The catch-all trap
catch (e) catches everything, including programming mistakes like TypeError: cannot read property of undefined. Advanced practice narrows the catch:
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
async function saveUser(input) {
try {
validate(input);
return await api.post('/users', input);
} catch (err) {
if (err instanceof ValidationError) {
return { ok: false, reason: 'validation', field: err.field };
}
throw err; // rethrow unknown failures
}
}Notice the rethrow. Swallowing unknown errors converts a loud bug into silent corruption.
Failure modes
- finally interactions:
finallyruns even duringreturnorthrowfrom thetryblock. Areturninsidefinallysilently discards any pending throw — never do it. - Async errors escape try blocks: a
throwinside a promise you forgot toawaitwill not hit yourcatch. This is the most common async failure mode:
try {
doAsyncWork().then(report); // rejection escapes this catch
} catch (err) { /* never runs for a rejection */ }
try {
await doAsyncWork(); // awaited — the catch sees the rejection
} catch (err) { /* runs */ }- Floating promises: any call returning a promise without
awaitor.catch()is an unhandled rejection risk. Top-level, useawaitor attach a handler. - Lossy rethrows: wrapping an error in a new one without a
causedestroys the original stack. Use thecauseoption:
try {
parseConfig(raw);
} catch (err) {
throw new Error('config invalid', { cause: err });
}finally as cancellation
finally is for symmetric cleanup, not control flow:
const handle = openResource();
try {
process(handle);
} finally {
handle.close(); // runs on success, throw, or early return
}Decision criteria
| Situation | Approach |
|---|---|
| Expected, recoverable (validation, 404) | Catch, inspect type, return a result |
| Programming bug (undefined access) | Let it crash; don't catch broadly |
| Retryable I/O | Catch, wrap with `cause`, retry with backoff |
| Top-level boundary (server request, event handler) | Catch-all, log with full stack, respond safely |
Rule of thumb: catch only what you can meaningfully handle at that layer; otherwise enrich and rethrow.
diagram mermaid caption: Error propagation from a nested async call to a top-level boundary. alt: Flow diagram showing a throw inside doAsyncWork propagating through saveUser's try/catch, being rethrown, and finally caught by the top-level handler.
exercise Q: Why doesn't catch around a non-awaited promise call observe its rejection? A: The promise's rejection is asynchronous and happens after the try block finishes; without await (or .catch), no code in that try is linked to the promise's eventual failure, so the rejection escapes as an unhandled rejection.
Spotted a mistake, or code that does not work?