Error Handling
Recoverable vs Fatal
Not all errors are equal. Classify before you handle.
| Category | Description | Example | Action |
|---|---|---|---|
| Recoverable | Expected failure, caller can adapt | File not found, validation failure, network timeout | Return error value or throw typed error |
| Transient | Temporary condition, retry may succeed | Rate-limit 429, DB connection blip | Retry with backoff, then escalate |
| Fatal | System cannot continue safely | Missing required env var on startup, corrupted state | Log + exit (or crash the request, not the process) |
| Programming error | Bug in the code itself | TypeError: Cannot read property of undefined | Let it propagate — do not catch bugs |
Rule: Never catch a TypeError, RangeError, or ReferenceError unless you specifically expect it. These indicate bugs, not runtime conditions.
Typed Error Classes
Give errors machine-readable codes. Avoid stringly-typed message checks.
// ✅ CORRECT — typed, discriminable, chainable
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
options?: ErrorOptions,
) {
super(message, options); // passes { cause } through
this.name = this.constructor.name;
// Fix prototype chain for instanceof checks in transpiled code
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string, options?: ErrorOptions) {
super(`${resource} with id "${id}" not found`, 'NOT_FOUND', 404, options);
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public readonly fields: Record<string, string[]>,
options?: ErrorOptions,
) {
super(message, 'VALIDATION_FAILED', 422, options);
}
}
export class ConflictError extends AppError {
constructor(message: string, options?: ErrorOptions) {
super(message, 'CONFLICT', 409, options);
}
}
// ❌ WRONG — string-matching error messages is fragile
try {
await getUser(id);
} catch (err) {
if ((err as Error).message.includes('not found')) {
return null;
}
throw err;
}
// ✅ CORRECT — discriminate by type or code
try {
await getUser(id);
} catch (err) {
if (err instanceof NotFoundError) return null;
throw err;
}
Result Pattern for Expected Failures
When a function has a known failure mode that the caller must handle, prefer returning a Result<T, E> over throwing.
// Result type (no library needed)
type Result<T, E = AppError> =
| { ok: true; value: T }
| { ok: false; error: E };
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
// ✅ CORRECT — caller is forced to handle both cases
async function findUser(id: string): Promise<Result<User, NotFoundError>> {
const user = await db.users.findUnique({ where: { id } });
if (!user) return err(new NotFoundError('User', id));
return ok(user);
}
const result = await findUser(userId);
if (!result.ok) {
// TypeScript knows result.error is NotFoundError here
return res.status(404).json({ error: result.error.code });
}
// TypeScript knows result.value is User here
doSomethingWith(result.value);
// ❌ WRONG — caller can forget to handle the failure
async function findUser(id: string): Promise<User | null> {
const user = await db.users.findUnique({ where: { id } });
return user ?? null;
// Caller can forget the null check entirely
}
Never Swallow Errors Silently
An empty catch block is one of the most dangerous patterns in any codebase.
// ❌ WRONG — error disappears completely
try {
await sendWelcomeEmail(user.email);
} catch (_) {
// silence
}
// ❌ WRONG — logged but the original error chain is lost
try {
await sendWelcomeEmail(user.email);
} catch (err) {
console.log('email failed');
}
// ✅ CORRECT — log with context, decide if it's fatal
try {
await sendWelcomeEmail(user.email);
} catch (err) {
logger.warn({ err, userId: user.id }, 'Welcome email delivery failed');
// Intentionally non-fatal: user still gets registered, email can be retried
// If this MUST succeed, re-throw instead
}
Rule: Every catch block must do at least one of: log, re-throw, or return an error value. A comment explaining why you chose silence is mandatory when silence is truly correct.
Propagate with Context (Error Cause Chain)
When re-throwing, wrap with context. Never discard the original error.
// ❌ WRONG — original stack trace and cause are gone
try {
await db.query(sql);
} catch {
throw new Error('Database query failed');
}
// ✅ CORRECT — chain with { cause }, preserving the full error trail
try {
await db.query(sql);
} catch (err) {
throw new AppError(
`Failed to load user profile for id="${userId}"`,
'DB_QUERY_FAILED',
500,
{ cause: err }, // native Error cause chain (Node 16.9+)
);
}
Printing the full cause chain:
function formatErrorChain(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const parts = [err.message];
let cause = err.cause;
while (cause instanceof Error) {
parts.push(`caused by: ${cause.message}`);
cause = cause.cause;
}
return parts.join('\n ');
}
User-Facing vs Log-Only Messages
Never expose internal details (SQL, stack traces, internal IDs) in API responses.
// ❌ WRONG — leaks DB schema, query, and stack trace
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
res.status(500).json({
error: (err as Error).message, // may contain SQL or file paths
stack: (err as Error).stack,
});
});
// ✅ CORRECT — safe external message + full detail in logs
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const appErr = err instanceof AppError ? err : null;
const statusCode = appErr?.statusCode ?? 500;
// Full details go to structured log (internal only)
logger.error({
err,
req: { method: req.method, url: req.url, traceId: req.traceId },
}, 'Request failed');
// Sanitized response goes to client
res.status(statusCode).json({
error: {
code: appErr?.code ?? 'INTERNAL_ERROR',
message: appErr?.statusCode && appErr.statusCode < 500
? appErr.message // 4xx: safe to expose, it's the caller's fault
: 'An unexpected error occurred', // 5xx: hide internals
},
});
});
Rule:
- 4xx errors (client mistakes): message is safe to show — it describes what the caller did wrong.
- 5xx errors (server failures): use a generic message. Log the real error internally.
Unhandled Rejection / Uncaught Exception
// Register at process entry point — do this exactly once
process.on('unhandledRejection', (reason, promise) => {
logger.fatal({ reason, promise }, 'Unhandled Promise rejection');
// Exit cleanly — let the process manager restart
process.exit(1);
});
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception');
process.exit(1);
});
Rule: Crash fast and let the process manager (systemd, PM2, container orchestrator) restart. A process running in an unknown state is more dangerous than a restarted one.
Checklist
- Every error has a machine-readable
codeproperty - No empty
catchblocks — each one logs, re-throws, or returns an error value - Re-throws use
{ cause: originalErr }to preserve the chain - 5xx responses never include stack traces, SQL, or internal paths
-
process.on('unhandledRejection')registered at startup - Typed error subclasses for all expected failure modes
-
instanceofchecks used for error discrimination, notmessage.includes()