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.name so logs say ValidationError rather than Error.
  • Pass { cause } when wrapping a lower-level failure; it preserves the original error for the debugger.
  • Give machine-readable code values 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(', '));
SituationDoWhy
A value you can replaceReturn a defaultCallers get a usable result
A recoverable operationRetry, then rethrowTransient failure should not surface
An unknown error from a libraryRethrow unchangedYou cannot classify what you did not expect
A missing recordReturn null or throw NotFoundErrorDo not force callers to catch for a normal outcome
A programmer mistakeThrow immediatelyCrash 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 unhandledrejection handlers so nothing disappears without a trace.
  • console.error the whole error object, not err.message alone — 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.

Modules and project structure The event loop, timers and concurrency

Last refreshed 2026-09-18.