Two Philosophies Walk Into a Runtime
There's a fundamental split in how programming languages handle errors. On one side: exceptions. Python, Java, C#, JavaScript — they all throw exceptions that unwind the call stack until something catches them. On the other side: return values. Go returns (result, error) tuples. Rust returns Result<T, E>. Haskell uses Either.
Both work. Both have sharp edges. The question isn't which is "right" — it's which tradeoffs fit your situation.
Exceptions: The Good Parts
Exceptions separate the happy path from error handling. Your business logic reads top-to-bottom without error checks interrupting every other line:
# Python: the happy path reads cleanly
def process_order(order_id: int) -> OrderConfirmation:
order = fetch_order(order_id) # might raise NotFoundError
validate_inventory(order) # might raise OutOfStockError
payment = charge_customer(order) # might raise PaymentError
confirmation = create_shipment(order) # might raise ShippingError
return confirmation
# Error handling happens at the boundary
try:
result = process_order(order_id)
except NotFoundError:
return Response(status=404, body="Order not found")
except OutOfStockError as e:
return Response(status=409, body=f"Out of stock: {e.item}")
except PaymentError:
return Response(status=402, body="Payment failed")
The happy path is 5 lines. Clean. Readable. You know exactly what the function does.
Exceptions also propagate automatically. If charge_customer calls validate_card which calls check_fraud_score, and the fraud check fails, the exception bubbles up through all three functions without any of them explicitly handling it. This is powerful for deeply nested call stacks.
Exceptions: The Problems
The same automatic propagation that makes exceptions convenient makes them dangerous. Any function can throw any exception at any time, and there's nothing in the function signature that tells you this.
# What exceptions can this throw? Who knows!
def get_user_profile(user_id):
user = db.query(User, id=user_id) # ConnectionError? TimeoutError?
avatar = s3.get_object(user.avatar_key) # BucketNotFoundError? PermissionError?
return {"user": user, "avatar": avatar}
Java tried to fix this with checked exceptions — the compiler forces you to handle or declare every exception a method might throw. In theory, great. In practice, it led to:
// The checked-exception tax: catch-and-rethrow boilerplate everywhere
try {
return parseConfig(path);
} catch (IOException e) {
throw new RuntimeException(e); // give up and wrap it
}
Most Java developers eventually wrap everything in RuntimeException to escape the checked exception system. The compiler enforcement didn't survive contact with real codebases.
Result Types: Errors as Values
Go and Rust treat errors as regular return values. A function that might fail returns the error alongside the result:
// Go: explicit error checking at every step
func processOrder(orderID int64) (*OrderConfirmation, error) {
order, err := fetchOrder(orderID)
if err != nil {
return nil, fmt.Errorf("fetching order: %w", err)
}
if err := validateInventory(order); err != nil {
return nil, fmt.Errorf("validating inventory: %w", err)
}
payment, err := chargeCustomer(order)
if err != nil {
return nil, fmt.Errorf("charging customer: %w", err)
}
confirmation, err := createShipment(order)
if err != nil {
return nil, fmt.Errorf("creating shipment: %w", err)
}
return confirmation, nil
}
More verbose? Absolutely. But every potential failure point is visible. You can't accidentally ignore an error — well, you can with _ = someFunc(), but it's an explicit choice.
Rust's Result type goes further with the ? operator that makes error propagation concise:
fn process_order(order_id: i64) -> Result<OrderConfirmation, AppError> {
let order = fetch_order(order_id)?; // ? returns early on error
validate_inventory(&order)?;
let payment = charge_customer(&order)?;
let confirmation = create_shipment(&order)?;
Ok(confirmation)
}
Nearly as readable as the Python exception version, but the function signature tells you it can fail and what error type it returns. The ? operator handles early returns automatically.
Error Boundaries in React
Frontend applications face a unique error handling challenge: an uncaught exception in one component shouldn't crash the entire page. React's Error Boundaries provide isolation:
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Send to error tracking (Sentry, etc.)
logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
// Usage: isolate feature sections
<ErrorBoundary>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary>
<OrderHistory /> {/* crash here doesn't affect UserProfile */}
</ErrorBoundary>
The pattern is: wrap independent UI sections in their own Error Boundaries. If the order history component throws, the user still sees their profile. The crashed section shows a fallback UI ("Something went wrong. Reload this section") instead of a white screen.
Building an Error Hierarchy
Regardless of mechanism (exceptions or Result types), organize errors into categories that map to caller responses:
- Client errors — bad input, missing required fields, unauthorized. The caller should fix their request.
- Dependency errors — database down, external API timeout. The caller should retry or show a degraded experience.
- Programming errors — null pointer, index out of bounds. The caller can't recover. These are bugs.
# Python: error hierarchy
class AppError(Exception):
# Base error for the application.
pass
class ClientError(AppError):
# Caller sent bad input.
pass
class NotFoundError(ClientError):
pass
class ValidationError(ClientError):
def __init__(self, field: str, message: str):
self.field = field
super().__init__(f"{field}: {message}")
class DependencyError(AppError):
# External service failed.
def __init__(self, service: str, cause: Exception):
self.service = service
self.cause = cause
super().__init__(f"{service} failed: {cause}")
This hierarchy lets HTTP handlers map errors to status codes cleanly. ClientError → 4xx. DependencyError → 503. Unhandled exceptions → 500.
Practical Rules
- Don't catch exceptions you can't handle meaningfully. A bare
except: passhides bugs. - Add context when wrapping errors. "database error" is useless. "Failed to fetch user 12345 from PostgreSQL: connection refused" is actionable.
- Log at the boundary, not at every layer. If you log an error and then re-raise it, every layer above will log it again. Log once, at the top.
- For Go: always wrap errors with
fmt.Errorf("context: %w", err). The%wverb preserves the original error forerrors.Is()anderrors.As()checks. - For async code: unhandled promise rejections in Node.js crash the process by default (since v15). Always
.catch()orawaityour promises.