ehsan.blog
~/blog/api-client-part-9-error-handling-safemode — zsh
cat api-client-part-9-error-handling-safemode.md

Stop catching generic Errors: typed error classes and safeMode

·5 min read

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.

ts
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 wrong
ts
try {
  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.

ts
createClient({ safeMode: true })
ts
// 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.

ts
// 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:

ts
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.
  • ConfigurationError means your code called the client wrong, thrown before any network call — it belongs in logs, not user-facing UI.
  • safeMode swaps 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.retry config — 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.