Error handling is one of those things that seems simple until your system is on fire at 3 AM and the logs say “Something went wrong.” Investing in good error handling pays for itself many times over.
Typed Errors
Don’t throw generic errors. Create a hierarchy:
class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number,
public context?: Record<string, unknown>
) {
super(message);
this.name = "AppError";
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(
`${resource} not found: ${id}`,
"NOT_FOUND",
404,
{ resource, id }
);
}
}
class ValidationError extends AppError {
constructor(errors: FieldError[]) {
super(
"Validation failed",
"VALIDATION_ERROR",
400,
{ errors }
);
}
}
Now your error handler can make intelligent decisions based on error type.
Result Types (No Exceptions)
For expected failures, return results instead of throwing:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function getUser(id: string): Promise<Result<User, "not_found" | "db_error">> {
try {
const user = await db.users.findById(id);
if (!user) return { ok: false, error: "not_found" };
return { ok: true, value: user };
} catch {
return { ok: false, error: "db_error" };
}
}
// Caller is forced to handle both cases
const result = await getUser("42");
if (!result.ok) {
// Handle error -- compiler knows the possible error values
return;
}
// result.value is User here
Exceptions should be exceptional. Expected business logic failures (user not found, validation failed, insufficient balance) are better modeled as values.
Error Boundaries
In any system, define clear boundaries where errors are caught and handled:
// HTTP handler -- the outermost boundary
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) {
res.status(err.statusCode).json({
error: { code: err.code, message: err.message },
});
} else {
// Unknown error -- log everything, return generic message
logger.error("Unhandled error", {
error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
res.status(500).json({
error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred" },
});
}
});
Retry with Backoff
For transient failures, retry with exponential backoff:
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries) throw err;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("unreachable");
}
The jitter (Math.random() * 1000) prevents thundering herd problems when many clients retry simultaneously.
Context in Errors
Every error should carry enough context to debug the issue:
- What operation was being performed?
- What were the inputs?
- What was the system state?
Don’t log “Database error.” Log “Failed to update user.email for user_id=42: connection timeout after 5000ms.”
Good error handling is boring. That’s the point. When something goes wrong, you want boring, predictable, well-structured information — not surprises.