Errors, exceptions and defensive code
throw and Error subclasses, try/catch/finally, how errors propagate through async code, custom error shapes and a practical debugging strategy.
Errors and custom error types
Anything can be thrown in JavaScript, but throw an Error (or a subclass) so you keep a stack trace and a message. A thrown string has neither, and every handler then needs its own guessing logic.
class AppError extends Error {
constructor(message, options = {}) {
super(message, { cause: options.cause }); // keep the original reason
this.name = new.target.name; // survives transpilation best
this.code = options.code ?? 'APP_ERROR';
}
}
class ValidationError extends AppError {
constructor(field, message) {
super(message, { code: 'VALIDATION' });
this.field = field;
}
}
class NotFoundError extends AppError {
constructor(what) { super(what + ' not found', { code: 'NOT_FOUND' }); }
}
function parseAge(input) {
const age = Number(input);
if (!Number.isInteger(age) || age < 0) {
throw new ValidationError('age', 'age must be a non-negative integer');
}
return age;
}
try {
parseAge('abc');
} catch (err) {
console.log(err.name, err.code, err.field); // ValidationError VALIDATION age
console.log(err instanceof AppError); // true
console.log(err.stack.split('\n')[1]); // the call site
}- Set
this.nameso logs sayValidationErrorrather thanError. - Pass
{ cause }when wrapping a lower-level failure; it preserves the original error for the debugger. - Give machine-readable
codevalues to errors that cross a boundary such as an HTTP response. - Fail fast on programmer mistakes (a missing required argument) and return a value for expected conditions such as a missing record.
try, catch and finally
function readSetting(key) {
try {
return JSON.parse(config.get(key)); // may throw SyntaxError
} catch (err) {
if (err instanceof SyntaxError) {
log.warn('bad JSON for ' + key, err);
return null; // handled: recover with a default
}
throw err; // not ours: let it propagate
} finally {
metrics.timing('setting.read'); // always runs, even on return
}
}
// validate at the boundary, trust internally
function createUser(input) {
if (typeof input?.email !== 'string') {
throw new ValidationError('email', 'email is required');
}
return save(input); // save() assumes valid data
}
// never swallow silently
try { risky(); } catch { } // worst line in any codebase
// grouping several checks
const problems = [];
if (!name) problems.push('name is empty');
if (problems.length) throw new ValidationError('form', problems.join(', '));| Situation | Do | Why |
|---|---|---|
| A value you can replace | Return a default | Callers get a usable result |
| A recoverable operation | Retry, then rethrow | Transient failure should not surface |
| An unknown error from a library | Rethrow unchanged | You cannot classify what you did not expect |
| A missing record | Return null or throw NotFoundError | Do not force callers to catch for a normal outcome |
| A programmer mistake | Throw immediately | Crash early and loudly instead of corrupting state |
⚠️
A
return inside finally overrides any value or exception from try and silently discards errors. Use finally only for cleanup — closing a connection, hiding a spinner — never for a return or a throw.Errors in async code
A rejected promise becomes an exception where you await it. A helper that calls an async function without awaiting it creates an unhandled rejection that no surrounding try can catch, because the surrounding code has already finished.
async function load(id) {
try {
const res = await fetch('/api/items/' + id);
if (!res.ok) throw new NotFoundError('item ' + id);
return await res.json();
} catch (err) {
throw new AppError('could not load item', { cause: err, code: 'LOAD_FAILED' });
}
}
// a promise chain is not caught by an outer try unless you await or return it
try {
items.forEach(id => { load(id); }); // detached: try cannot see the rejection
} catch (err) { /* never runs for the failures above */ }
await Promise.all(items.map(load)); // now the try block works
const settled = await Promise.allSettled(items.map(load)); // partial success
// last line of defence
window.addEventListener('unhandledrejection', event => {
report(event.reason);
event.preventDefault(); // stop the console noise if you report it
});
process.on('unhandledRejection', reason => { report(reason); process.exitCode = 1; });
// always give a fetch a deadline, or a hung request holds the scope open
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 8000);
try { return await fetch(url, { signal: ac.signal }); }
finally { clearTimeout(timer); }- Return or await every promise you start; a floating promise is an invisible failure.
- Register
unhandledrejectionhandlers so nothing disappears without a trace. console.errorthe whole error object, noterr.messagealone — the stack is the useful part.- Reproduce a bug by reading the first line of the stack that belongs to your own code; everything above it is usually framework internals.
FAQ
Should I catch errors or let them bubble?
Catch only where you can do something meaningful: recover, add context, or show a message. Everywhere else, let the error travel to an application-level handler that logs it once with full context.
Why does my catch block get an unexpected type?
Any code you call can throw anything, including a bare string from a legacy library. Check with
err instanceof Error before reading message, and normalise unknown values into an Error at that point.Related
Modules and project structure The event loop, timers and concurrency
Last refreshed 2026-09-18.