Error handling is the part of software that separates a prototype from a production system. A prototype crashes and you restart it; a production system degrades gracefully, retries the right things, tells users what went wrong, and gives engineers the context to fix it. Getting there requires intentional decisions about what errors mean, where to handle them, and how to communicate them — not just wrapping everything in try/catch and hoping.
What changed in 2026
- TypeScript
using declarations (ES2023, now universal) ensure cleanup on scope exit even when exceptions throw, replacing many manual try/finally patterns.
- Node.js 22 made unhandled promise rejections fatal — silent promise failures that previously swallowed errors now crash the process by default.
- Rust-style Result types gained popularity in TypeScript via libraries like
neverthrow and oxide-ts — making error paths visible in function signatures.
- OpenTelemetry became the standard for propagating error context through distributed systems; structured error metadata flows through traces automatically.
Operational vs programmer errors
| Type |
Meaning |
Response |
| Operational error |
Expected failure in a running system |
Handle, retry, or degrade gracefully |
| Programmer error |
Bug — the code did something wrong |
Fix the code; do not catch and continue |
Examples of operational errors: network timeout, 404 from an upstream API, database connection refused, user input validation failure, file not found.
Examples of programmer errors: TypeError: Cannot read properties of undefined, wrong arguments passed to a function, assertion violation.
Never "handle" a programmer error by swallowing it. Let it crash and fix the bug.
The golden rules
// WRONG — empty catch is always wrong
try {
await db.save(order);
} catch (err) {} // silent data loss
// WRONG — catch without context
try {
await processPayment(charge);
} catch (err) {
throw new Error("Payment failed"); // stack trace is gone
}
// RIGHT — log with context, rethrow with cause
try {
await processPayment(charge);
} catch (err) {
logger.error({ chargeId: charge.id, err }, "payment failed");
throw new PaymentError("Payment processing failed", { cause: err });
}
Typed errors in TypeScript
Custom error classes make error types explicit and catchable:
class NotFoundError extends Error {
constructor(
public readonly resource: string,
public readonly id: string,
) {
super(`${resource} ${id} not found`);
this.name = "NotFoundError";
}
}
class ValidationError extends Error {
constructor(public readonly fields: Record<string, string>) {
super("Validation failed");
this.name = "ValidationError";
}
}
// Caller can discriminate
try {
const user = await userService.findById(id);
} catch (err) {
if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
if (err instanceof ValidationError) return res.status(400).json({ errors: err.fields });
throw err; // unknown errors propagate
}
Result type pattern
For functions that frequently fail, a Result<T, E> type makes the error path visible without exceptions:
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
async function parseConfig(raw: string): Promise<Result<Config, string>> {
try {
return { ok: true, value: JSON.parse(raw) };
} catch {
return { ok: false, error: "Invalid JSON in config" };
}
}
const result = await parseConfig(input);
if (!result.ok) {
console.error(result.error);
process.exit(1);
}
// result.value is narrowed to Config here
Python error handling
# Custom exception hierarchy
class AppError(Exception):
pass
class NotFoundError(AppError):
def __init__(self, resource: str, id: str):
super().__init__(f"{resource} {id} not found")
self.resource = resource
self.id = id
# Proper re-raise with context
try:
user = await user_repo.find(user_id)
except DatabaseError as exc:
raise NotFoundError("User", user_id) from exc # preserves original traceback
How to pick
- Recoverable, expected failure — catch, log with context, return a fallback or retry.
- Error that changes the response — catch at the HTTP boundary, map to the right status code.
- Bug / programmer error — let it crash; do not catch.
- Function that frequently returns "not found" or "invalid" — consider a Result type.
Common mistakes
Catching Error and re-throwing a new Error without { cause } — the original stack trace is lost.
Catching in every function. Catch at the boundary where you can take action. Intermediate functions should let errors propagate.
Generic error messages. "Something went wrong" is useless to both users and engineers. Include the resource, the id, and the reason.
Logging after rethrowing (double-logging). Log once — either here if you handle it, or at the top-level boundary if you rethrow.
What to skip
- try/catch around every
await — unhandled rejections now crash the process, which is usually what you want for programmer errors.
- Catch-all middleware that returns 500 for known operational errors — map known error types to appropriate status codes upstream.
- Error codes as magic numbers — use descriptive string constants or enum members.
FAQ
Should I use error codes or error messages?
Both. A machine-readable code (NOT_FOUND, PAYMENT_DECLINED) for programmatic handling; a human-readable message for logs and user interfaces.
What is error.cause in JavaScript?
new Error("message", { cause: originalError }) chains errors without losing the original stack. Accessible as err.cause. Supported natively since Node.js 16.9.
How do I handle errors in async event handlers?
Async event handlers that throw are unhandled by default. Wrap with an explicit try/catch and emit to an error handler: emitter.on("data", async (d) => { try { await process(d); } catch (err) { emitter.emit("error", err); } }).
What is the difference between throw and reject?
In async code, throwing inside an async function rejects the returned promise. They are equivalent. Prefer throw for consistency with synchronous style.
Where to go next
See Logging explained in 2026, Async/await explained in 2026, and How to handle errors gracefully in 2026.