Stop catching generic Errors: typed error classes and safeMode
catch (err) { showToast('Something went wrong') } is how most apps handle
API errors, and it’s how most apps end up showing “Something went wrong” for
a validation error the user could have actually fixed themselves. This post
is about not doing that.
The problem with one generic catch block
Problem: a timeout, a 404, a 422 validation failure, and a network
outage are four completely different situations that call for four different
UI responses — retry automatically, show “not found,” show field-level
validation messages, show “you’re offline.” A single catch (err: any)
block can’t tell them apart without string-matching err.message, which is
fragile and breaks the moment an error message changes.
Solution: every failure from the pipeline is one of a small set of typed error classes, so you branch on the type, not on parsing text.
class ApiError extends Error {
status?: number
data?: unknown
}
class NetworkError extends ApiError {} // no response at all — offline, DNS, etc.
class TimeoutError extends ApiError {} // request exceeded its timeout
class ValidationError extends ApiError { // 4xx with a structured body
errors: Record<string, string[]>
}
class AuthError extends ApiError {} // 401/403, or refresh failure
class ConfigurationError extends Error {} // your code called the client wrongtry {
await api.users.create(input)
} catch (err) {
if (err instanceof ValidationError) {
setFieldErrors(err.errors) // field-level messages, straight from the API
} else if (err instanceof AuthError) {
redirectToLogin()
} else if (err instanceof TimeoutError) {
showToast('That took too long — try again')
} else if (err instanceof NetworkError) {
showOfflineBanner()
} else {
showToast('Something went wrong') // genuinely unexpected — the honest fallback
}
}Real talk: that last else branch is still there, and it should be —
not every failure fits a category, and pretending otherwise just means
someone eventually writes an error class for a one-off case that didn’t need
one. The point isn’t zero generic handling, it’s that the generic branch
becomes the rare exception instead of the entire strategy.
ConfigurationError is not like the others
This one’s worth calling out separately: it means your code made a mistake
— a missing path param, a module referenced that doesn’t exist, invalid
config. It’s thrown before any network call happens, deliberately, so it
can’t be confused with an actual API failure. If you’re catching
ConfigurationError in production error-handling UI, that’s a sign of a bug
in your own code, not something to show the user — it belongs in your logs,
not a toast.
safeMode — trading exceptions for a result object
Problem: try/catch around every single API call gets verbose fast,
especially in code that’s mostly sequential API calls with straightforward
error paths — a lot of try { ... } catch (err) { return handleErr(err) }
boilerplate.
Solution: safeMode changes what a call resolves to, instead of what it
throws.
createClient({ safeMode: true })// safeMode: false (default) — throws, use try/catch
const user = await api.users.get('42')
// safeMode: true — never throws, returns a Result-shaped object
const result = await api.users.get('42')
if (result.ok) {
console.log(result.data)
} else {
console.log(result.error) // same typed error classes from above, still typed
}This is the same idea as Rust’s Result<T, E> or Go’s (value, err)
pattern, applied to API calls. Nothing about the underlying pipeline changes
— you still get the same typed errors, the same retry and cache behavior —
only the calling convention changes, from throw-based to return-based.
When I’d actually reach for safeMode, and when I wouldn’t
I don’t set it globally on most projects. My honest take: exceptions are the
right default for API calls in a UI codebase, because React error boundaries
and top-level error handling already expect them, and mixing try/catch
code with if (result.ok) code in the same codebase is more confusing than
either style alone.
Where I do reach for it: scripts and background jobs — a data-sync script
that processes a thousand records and needs to keep going even when
individual records fail. try/catch around every iteration is exactly the
kind of boilerplate safeMode is built to remove.
// A sync script — safeMode makes the "keep going on failure" case natural
for (const record of records) {
const result = await api.records.sync(record)
if (!result.ok) {
failures.push({ record, error: result.error })
continue
}
succeeded.push(result.data)
}You can also set it per-call instead of globally, if you want the throwing default everywhere except one specific bulk operation:
const result = await api.records.sync(record, { safeMode: true })Errors and the retry pipeline — how they connect
Worth tying back to the resilience post: retryOn (which status codes get
retried) and these typed error classes are related but not the same
mechanism. A TimeoutError is what you catch after the pipeline has
already exhausted its retry attempts — by the time you see it, the pipeline
tried, backed off, and tried again per your retry config, and only
surfaces the error once retries are genuinely exhausted. You’re not choosing
between “retry” and “typed error” — you get both, in sequence: retries
happen first, silently, and the typed error is what’s left if they all fail.
What sticks from this post
- Typed error classes (
NetworkError,TimeoutError,ValidationError,AuthError,ConfigurationError) let you branch on failure type instead of parsing error strings. ConfigurationErrormeans your code called the client wrong, thrown before any network call — it belongs in logs, not user-facing UI.safeModeswaps throwing for a{ ok, data | error }result — good for scripts and bulk jobs that need to keep going past individual failures, less natural mixed into typical UI code.- Retries happen first, automatically, per your
http.retryconfig — the typed error you catch is what’s left after retries are exhausted, not an alternative to them.
Next: hooks and events — shaping every outgoing request and reacting to lifecycle events globally, without touching every method.
- developerehsan