Error Handling
Rule
Rules for error handling across the stack. See api.md for API error format and typescript.md for type-level rules.
Philosophy
- MUSTLet errors propagate unless you can recover, transform, or report.
- MUSTDistinguish between expected errors (validation, 404, auth) and unexpected crashes (null ref, network failure).
- MUSTFail fast. Surface bugs immediately. A crash with a stack trace is better than silent wrong behavior.
- NEVERSwallow errors silently. No empty
catch {}blocks. - NEVERUse fallback values to hide failures. If data is missing and shouldn't be, throw — don't return
[],null, or a default.
Silent Fallbacks (LLM Anti-Pattern)
LLM-generated code systematically hides bugs behind defensive fallbacks. These patterns make code "always work" by silently degrading instead of surfacing the real problem. Every fallback must be intentional and justified — not a safety blanket.
Patterns to Reject
// BAD: Hides a broken API response behind an empty array
const users = response.data?.users ?? [];
// GOOD: If users should exist, crash loudly
const users = response.data.users; // TypeError if missing = good, you'll find the bug
// GOOD: If it's genuinely optional, be explicit about why
const users = response.data?.users ?? []; // API returns null for new accounts with no users
// BAD: Catch-and-return-default hides the actual error
try {
const config = await loadConfig();
} catch {
return DEFAULT_CONFIG; // Bug in loadConfig() now invisible forever
}
// GOOD: Let it throw — the caller should know config loading failed
const config = await loadConfig();
// GOOD: If you must catch, catch specifically and re-throw unknown errors
try {
const config = await loadConfig();
} catch (error) {
if (error instanceof FileNotFoundError) {
return DEFAULT_CONFIG; // Intentional: first run has no config file
}
throw error; // Unknown errors propagate
}
// BAD: Optional chaining as a band-aid
const title = post?.metadata?.title ?? "Untitled";
// GOOD: If post and metadata should exist at this point, access directly
const title = post.metadata.title; // Crash = bug in data loading upstream
// BAD: try/catch around trusted internal code
try {
const result = formatUserName(user);
} catch {
return "Unknown User"; // formatUserName bug now invisible
}
// GOOD: Trust internal code. If it throws, that's a bug to fix.
const result = formatUserName(user);
When Fallbacks ARE Correct
- System boundaries: External API responses, user input, webhook payloads — you can't trust the shape.
- Graceful degradation by design: Feature flags, optional enhancements, progressive loading.
- Documented optionality: The value is genuinely nullable by design, not because something failed.
The test: Can you explain why this fallback exists without saying "just in case"? If not, remove it.
Server-Side
- MUSTAPI errors use the standard error shape. See api.md.
- MUSTLog unexpected errors with stack traces and request context (user ID, route, timestamp).
- SHOULDUse an error tracking service (Sentry) for production. Alert on new error patterns, not every occurrence.
- NEVERCatch errors just to re-throw them unchanged.
Client-Side
- MUSTAdd React error boundaries at route-level to catch render crashes.
- SHOULDUse toast notifications for recoverable errors (failed saves, network issues).
- SHOULDUse full-page error states for unrecoverable errors (auth expired, 500).
- SHOULDProvide a retry action where the operation is idempotent.
Error Pages
- MUSTCustom 404 page with navigation back to a working state.
- SHOULDCustom 500 page that doesn't depend on any data fetching.
- SHOULD
error.tsx(Next.js) or equivalent at the root layout level.
Forms
- MUSTShow field-level validation errors inline, not in alerts or toasts.
- SHOULDValidate on blur for individual fields, on submit for the full form.