JavaScript Error Handling Patterns: try/catch, Result Types, and async/await

Link copied
JavaScript Error Handling Patterns: try/catch, Result Types, and async/await

JavaScript Error Handling Patterns: try/catch, Result Types, and async/await

JS Tutorial Module 5: Async Lesson 5.4

JavaScript's try/catch is deceptively simple. It catches synchronous throws, but misses the entire async universe unless you wire it up correctly. And even try/catch itself has corners — uncaught promise rejections, event handlers that swallow errors, error objects that lose their stack trace.

This is Part 11 of the JavaScript Fundamentals series. We cover what try/catch will and will not catch, the Result-type pattern as an alternative to throwing, async error propagation, and the patterns that actually work in production.

The basics, fast #

try {
  doWork();
} catch (err) {
  console.error('Something broke:', err);
} finally {
  cleanup();
}
  • try block — code that might throw.
  • catch (err) — runs if try throws synchronously. err is whatever was thrown.
  • finally — runs whether or not there was an error. Runs even if try or catch returns.

Since ES2019 the catch binding is optional:

try { doWork(); } catch { /* don't care what error */ }

This is fine when you only care that an error happened, not what it was — for example, when probing for an optional feature.

Errors are objects (and you can extend them) #

throw accepts any value, but you should always throw Error instances. The reason is .stack — only Error objects capture a stack trace:

throw new Error('Configuration missing');
throw 'Configuration missing';   // no stack trace, hard to debug

Subclass Error for typed errors you can branch on:

class NotFoundError extends Error {
  constructor(resource) {
    super(`Not found: ${resource}`);
    this.name = 'NotFoundError';
    this.resource = resource;
  }
}

try {
  loadUser(id);
} catch (err) {
  if (err instanceof NotFoundError) return null;
  throw err; // unknown errors propagate
}

This is far better than parsing error messages — a brittle and i18n-hostile pattern.

What try/catch will NOT catch #

This is where most bugs live. try/catch catches synchronous throws inside the try block only.

Async callbacks #

try {
  setTimeout(() => { throw new Error('boom'); }, 0);
} catch (err) {
  // ✗ NEVER reached
}

The setTimeout callback runs later, on a fresh stack (Part 7). The original try is long gone by then. The error becomes an uncaught exception.

Fix: move the try/catch inside the callback:

setTimeout(() => {
  try { riskyWork(); } catch (err) { /* handle */ }
}, 0);

Unhandled promise rejections #

try {
  fetch('/api').then(res => parse(res));
} catch (err) {
  // ✗ does not catch network errors or parse errors
}

fetch returns a Promise. The try block exits as soon as the Promise is returned (sync code is done). Any later rejection has nowhere to go but unhandledrejection.

Fix: await it inside an async function, or attach .catch:

// async/await
async function load() {
  try {
    const res = await fetch('/api');
    return await parse(res);
  } catch (err) { /* handle */ }
}

// or:
fetch('/api').then(parse).catch(err => /* handle */);

Errors inside event handlers #

button.addEventListener('click', () => {
  throw new Error('boom'); // logs to console; does NOT bubble to any wrapping try
});

DOM event handlers swallow errors and report them via window.onerror. Plan for it.

Errors in setInterval callbacks #

Same as setTimeout. Each iteration is its own stack.

Async error propagation with await #

await re-throws rejected promises. This is what lets try/catch look like sync code:

async function loadUser(id) {
  try {
    const res = await fetch(`/users/${id}`);
    if (!res.ok) throw new NotFoundError(`/users/${id}`);
    return await res.json();
  } catch (err) {
    if (err instanceof NotFoundError) return null;
    throw err;
  }
}

Note if (!res.ok) throwfetch only rejects on network failure, NOT HTTP error status. You have to inspect res.ok yourself. Endless production bugs come from this.

Promise.all and partial failure #

await Promise.all([loadA(), loadB(), loadC()]);
// If ANY rejects, the whole call rejects — others may still be running but are ignored.

When you want all results regardless:

const results = await Promise.allSettled([loadA(), loadB(), loadC()]);
results.forEach((r) => {
  if (r.status === 'fulfilled') use(r.value);
  else console.error(r.reason);
});

Promise.allSettled resolves to an array of { status, value } or { status, reason }. Use it when failures are individually recoverable.

Our post on Promises vs Observables for sequential calls covers more of these patterns.

The Result type pattern #

An alternative to throwing: return a typed { ok, value | error } object. Inspired by Rust:

async function loadUser(id) {
  try {
    const res = await fetch(`/users/${id}`);
    if (!res.ok) return { ok: false, error: 'not_found' };
    const user = await res.json();
    return { ok: true, value: user };
  } catch (err) {
    return { ok: false, error: 'network' };
  }
}

const result = await loadUser(42);
if (result.ok) use(result.value);
else logError(result.error);

Why use it:

  • The function signature forces the caller to handle the error case.
  • No surprise throws (the type says "this can fail").
  • Easy to plumb through pipelines without unwinding the stack.
  • Plays well with TypeScript discriminated unions.

Why NOT use it:

  • Verbose at the call site.
  • Doesn't compose as cleanly as await chains in happy paths.
  • Most JS ecosystems still throw, so you'd be swimming upstream.

My recommendation: use throws + try/catch for unexpected errors; use Result for expected-but-possible failure cases (validation, not-found, business-rule violations).

ES2025 Error.cause #

When you re-throw to add context, the original error often gets lost. ES2022 added cause:

try {
  await loadConfig();
} catch (err) {
  throw new Error('Startup failed', { cause: err });
}

Logging tools and modern Node print both the new message and the original cause chain. Use it liberally — it preserves debugging info that string-concatenated messages destroy.

The global safety nets #

Every production app should install these:

// Browser
window.addEventListener('error', (e) => {
  reportToSentry(e.error || e.message, e.filename, e.lineno);
});
window.addEventListener('unhandledrejection', (e) => {
  reportToSentry(e.reason);
});

// Node
process.on('uncaughtException', (err) => {
  reportToSentry(err);
  // Recommendation: log and exit. State may be corrupted.
  process.exit(1);
});
process.on('unhandledRejection', (err) => {
  reportToSentry(err);
  // Don't exit — recoverable in most apps.
});

These are last-resort handlers. They should log loudly. Do not use them for normal error handling.

Error boundaries (framework-level) #

React: <ErrorBoundary> catches errors in the React tree below it. Angular: a custom ErrorHandler. Vue: app.config.errorHandler. These wrap component render errors and let you show fallback UI instead of crashing the page. Always install one at the route root.

Common gotchas #

Gotcha 1: Stringifying errors loses info.

console.error(`Failed: ${err}`); // [object Object] or '...message...'
console.error('Failed:', err);    // logs full Error with stack — ✓

Use the comma form for console; use JSON.stringify(err, Object.getOwnPropertyNames(err)) if you need to serialize.

Gotcha 2: Swallowing errors with empty catch.

try { riskyWork(); } catch {} // ✗ silent failure

At minimum log it. Better, re-throw or branch on type. Empty catches are the #1 way bugs hide for months.

Gotcha 3: finally can override the throw.

function bad() {
  try { throw new Error('boom'); }
  finally { return 'oops'; } // ✗ swallows the throw!
}
bad(); // 'oops' — no error

Never return from finally unless you mean to override.

Gotcha 4: try/catch does NOT catch syntax errors. Syntax errors happen at parse time, before any code runs. They can only be caught by wrapping eval or dynamic import():

try { await import('./maybe-broken.js'); } catch (err) { /* parse error or runtime error */ }

Gotcha 5: Async iterators throw at the iterator step.

for await (const chunk of stream) { ... } // try/catch around the for-await works

Recap #

  • try/catch catches synchronous throws inside the try. Async callbacks and uncaught promises slip through.
  • await-throwing chains let you try/catch around the whole flow as if it were sync.
  • Always throw Error instances; subclass for typed branches.
  • Promise.all fails fast; Promise.allSettled waits for everyone.
  • The Result type pattern is a structured alternative to throwing — best for expected failure cases.
  • Use Error.cause to preserve the original error when re-throwing.
  • Install global error / unhandledrejection handlers in browser, uncaughtException / unhandledRejection in Node.
  • Never swallow errors with an empty catch. Never return from finally.

Next up: Prototypes and the Prototype Chain — what class really compiles to, how method lookup traverses the prototype chain, and the inheritance model behind every JavaScript object.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *