Errors — Throwing, Catching and Custom Types
IntermediateThrow Error objects, not strings. Catch where you can act, not everywhere. And know the two ways an async error escapes a try/catch.
Overview
Error handling is where sloppy async code becomes unmaintainable. JavaScript lets you throw anything, but throwing a string loses the stack trace, so always throw an Error or a subclass. Custom error classes let callers distinguish a validation failure from a network failure without parsing message strings. The two async-specific traps are a missing await, which turns a catchable throw into an unhandled rejection, and a throw inside a callback such as setTimeout, which no surrounding try/catch can reach.
Custom Error Types
A class per failure mode lets callers branch on type rather than on message text.
class ApiError extends Error {
constructor(message, status, body) {
super(message)
this.name = 'ApiError'
this.status = status
this.body = body
}
}
class ValidationError extends Error {
constructor(field, message) {
super(message)
this.name = 'ValidationError'
this.field = field
}
}
try {
await submit(form)
} catch (err) {
if (err instanceof ValidationError) highlight(err.field)
else if (err instanceof ApiError && err.status === 401) redirectToLogin()
else report(err)
}
// Preserving the original when re-throwing:
throw new ApiError('Checkout failed', 502, body, { cause: err })Where try/catch Does Not Reach
Two cases surprise people, and both produce an uncaught error despite a try/catch being right there.
// 1. Missing await — the rejection escapes the block
try {
fetchUser() // no await
} catch (e) {
// never runs; becomes an unhandled rejection
}
// 2. Throwing inside a callback that runs later
try {
setTimeout(() => { throw new Error('boom') }, 100)
} catch (e) {
// never runs — the stack is gone by the time it throws
}
// The safety nets, for logging rather than recovery:
window.addEventListener('unhandledrejection', e => report(e.reason))
window.addEventListener('error', e => report(e.error))Catching at the Right Level
Catch where you can do something about it. A catch that logs and continues with bad state is worse than no catch.
// Bad — swallows the failure, caller thinks it worked
async function load() {
try { return await fetchData() }
catch { return [] } // an empty list and an outage look identical
}
// Better — let it propagate to the level that can show an error state
async function load() {
return fetchData()
}
// And handle where the UI is:
try {
setItems(await load())
} catch (err) {
setError(err.message) // the user finds out
}
// Retry only what is worth retrying
async function withRetry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try { return await fn() }
catch (err) {
if (err.status && err.status < 500) throw err // 4xx will not fix itself
if (i === attempts - 1) throw err
await new Promise(r => setTimeout(r, 2 ** i * 200))
}
}
}Key Points to Remember
- 1Throw Error objects, never strings — a string has no stack trace
- 2Custom Error subclasses let callers branch on instanceof instead of parsing message text
- 3A missing await turns a catchable rejection into an unhandled one, even inside try/catch
- 4A throw inside setTimeout or another deferred callback cannot be caught by the surrounding try/catch
- 5Catch where you can act; returning an empty list on failure makes an outage indistinguishable from no data
Interview Questions
Sign in to ask AriaWhy should you throw an Error object rather than a string?
Give two situations where a try/catch will not catch an error that occurs inside it.
How would you implement retry with exponential backoff, and which errors should not be retried?
Ask Aria about Errors — Throwing, Catching and Custom Types
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.