Error Handling Strategies by Language: Exceptions, Result Types, and Error Values
What are the best error handling strategies by language?
TL;DR
- Bottom line: Use language-idiomatic error handling -- Result/Either types for recoverable errors in Rust/Go, exceptions for exceptional conditions in Python/Java/C#, and structured try/catch with typed errors in TypeScript.
- Key tool/command:
Result<T, E>(Rust),if err != nil(Go),try/except(Python),try/catchwith custom Error classes (TypeScript) - Watch out for: Swallowing errors silently -- the #1 cause of hard-to-debug production failures across all languages.
- Works with: Python 3.8+, TypeScript 4.0+/Node.js 15+, Go 1.13+ (error wrapping), Rust 1.0+, Java 8+, C# 6+.
Constraints
- Never swallow errors silently -- every caught error must be logged, propagated, or explicitly documented as intentionally ignored [src4]
- Match the error handling idiom to the language -- using Go-style error returns in Python or try/catch in Go produces unidiomatic, fragile code [src6]
- Async errors require different handling than sync errors -- unhandled Promise rejections crash Node.js 15+ processes by default [src7]
- Custom error types must preserve the original error chain for debugging -- always wrap, never replace [src2]
- Error messages exposed to end users must never contain stack traces, internal paths, or credentials [src5]
Quick Reference
| Language | Mechanism | Recoverable Errors | Fatal Errors | Custom Errors | Best Practice |
|---|---|---|---|---|---|
| Python | try/except | Catch specific exceptions | sys.exit(), unhandled exceptions | Subclass Exception | Catch narrow, raise with context (from e) |
| TypeScript/JS | try/catch + Promise | catch block, .catch() | process.exit(), unhandled rejection | Extend Error class | Use cause property (ES2022), typed error guards |
| Go | Error values (error interface) | if err != nil return | panic()/log.Fatal() | Implement error interface | Wrap with fmt.Errorf("%w", err), use errors.Is/As |
| Rust | Result<T, E> / Option<T> | match, ? operator | panic!(), .unwrap() | Implement std::error::Error | Use thiserror (libraries) / anyhow (applications) |
| Java | Checked + unchecked exceptions | try/catch | System.exit(), Error subclasses | Extend Exception or RuntimeException | Prefer unchecked for recoverable; checked for cross-boundary |
| C# | Exceptions (all unchecked) | try/catch/finally | Environment.FailFast() | Extend Exception | Use exception filters (when), avoid catch (Exception) |
Decision Tree
START
+-- Is the error recoverable?
| +-- YES
| | +-- Language uses Result types (Rust, Go)?
| | | +-- Rust --> Return Result<T, E>, caller uses ? or match
| | | +-- Go --> Return (value, error), caller checks if err != nil
| | +-- Language uses exceptions (Python, JS, Java, C#)?
| | +-- Can the caller reasonably handle it?
| | | +-- YES --> Throw specific exception, document in signature
| | | +-- NO --> Let it propagate to a higher-level handler
| | +-- Is it a validation/user-input error?
| | +-- YES --> Return error object/Result, don't throw
| | +-- NO --> Throw typed exception
| +-- NO (fatal/unrecoverable)
| +-- Rust --> panic!() -- process should crash
| +-- Go --> log.Fatal() or panic() with deferred cleanup
| +-- Python/JS/Java/C# --> Let exception propagate to top-level handler
+-- Should you log or propagate?
| +-- Low-level library/module --> Propagate (wrap with context), do NOT log
| +-- Application boundary (HTTP handler, CLI entrypoint) --> Log AND return error response
| +-- Background worker --> Log, report to error tracker, optionally retry
+-- Should you retry?
+-- Transient error (network timeout, rate limit, lock contention)?
| +-- YES --> Retry with exponential backoff (see retry-exponential-backoff unit)
| +-- NO (validation, auth, logic error) --> Do NOT retry, fail fast
+-- END
Step-by-Step Guide
1. Define your error hierarchy
Create a base error type for your domain, then specific subtypes for each failure mode. This enables callers to catch broad or narrow categories as needed. [src3]
# Python: error hierarchy
class AppError(Exception):
def __init__(self, message: str, code: str, cause: Exception | None = None):
super().__init__(message)
self.code = code
self.__cause__ = cause
class ValidationError(AppError):
def __init__(self, field: str, message: str):
super().__init__(message, code="VALIDATION_ERROR")
self.field = field
class NotFoundError(AppError):
def __init__(self, resource: str, identifier: str):
super().__init__(f"{resource} '{identifier}' not found", code="NOT_FOUND")
Verify: raise ValidationError("email", "invalid format") → expected: ValidationError: invalid format
2. Implement error wrapping to preserve context
When catching and re-raising errors, wrap the original to maintain the full chain. [src2] [src6]
// Go: error wrapping with fmt.Errorf %w
func fetchUser(id string) (*User, error) {
row, err := db.QueryRow("SELECT * FROM users WHERE id = $1", id)
if err != nil {
return nil, fmt.Errorf("fetchUser(%s): %w", id, err)
}
return &user, nil
}
// Caller: check wrapped errors
if errors.Is(err, sql.ErrNoRows) {
// handle not found
}
Verify: fmt.Println(errors.Is(wrappedErr, sql.ErrNoRows)) → true
3. Set up global error handlers
Catch unhandled errors at the application boundary to prevent crashes and ensure logging. [src7]
// TypeScript/Node.js: global handlers
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled rejection at:', promise, 'reason:', reason);
process.exit(1);
});
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
process.exit(1);
});
Verify: Trigger unhandled rejection with Promise.reject(new Error('test')) → should log and exit
4. Use typed error discrimination at catch sites
Instead of catching all errors and guessing, use type guards or pattern matching to handle specific error types. [src1]
// Rust: pattern matching on Result
fn read_config(path: &str) -> Result<String, AppError> {
fs::read_to_string(path).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => AppError::ConfigMissing(path.to_string()),
io::ErrorKind::PermissionDenied => AppError::PermissionDenied(path.to_string()),
_ => AppError::Io(e),
})
}
Verify: Call with non-existent path → should return Err(AppError::ConfigMissing(...))
Code Examples
Python: Structured Exception Handling with Context
# Input: HTTP request to external API
# Output: Parsed response or domain-specific error
import httpx # httpx>=0.27
class ApiError(Exception):
def __init__(self, status: int, body: str, cause: Exception | None = None):
super().__init__(f"API returned {status}: {body}")
self.status = status
self.__cause__ = cause
def fetch_data(url: str) -> dict:
try:
resp = httpx.get(url, timeout=10.0)
resp.raise_for_status()
return resp.json()
except httpx.TimeoutException as e:
raise ApiError(0, "request timed out", cause=e) from e
except httpx.HTTPStatusError as e:
raise ApiError(e.response.status_code, e.response.text, cause=e) from e
TypeScript: Async Error Handling with Result Pattern
// Input: Database query parameters
// Output: Result tuple [data, error] -- never throws
type Result<T> = [T, null] | [null, Error];
async function findUser(id: string): Promise<Result<User>> {
try {
const user = await db.query<User>('SELECT * FROM users WHERE id = $1', [id]);
if (!user) return [null, new NotFoundError(`User ${id}`)];
return [user, null];
} catch (err) {
return [null, err instanceof Error ? err : new Error(String(err))];
}
}
// Usage: no try/catch needed at call site
const [user, err] = await findUser('123');
if (err) {
logger.error('Failed to find user', { error: err, userId: '123' });
return res.status(err instanceof NotFoundError ? 404 : 500).json({ error: err.message });
}
Go: Sentinel Errors and errors.Is/As
// Input: File path to read
// Output: File contents or wrapped error
var ErrConfigNotFound = errors.New("config file not found")
type ConfigError struct {
Path string
Err error
}
func (e *ConfigError) Error() string {
return fmt.Sprintf("config error for %s: %v", e.Path, e.Err)
}
func (e *ConfigError) Unwrap() error { return e.Err }
func ReadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("%w: %s", ErrConfigNotFound, path)
}
return nil, &ConfigError{Path: path, Err: err}
}
return data, nil
}
Rust: thiserror for Library Errors
// Input: Config file path
// Output: Parsed config or typed error
use thiserror::Error; // thiserror = "2"
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("config file not found: {path}")]
NotFound { path: String },
#[error("invalid config format: {0}")]
ParseError(#[from] serde_json::Error),
#[error("I/O error reading config")]
Io(#[from] std::io::Error),
}
pub fn load_config(path: &str) -> Result<Config, ConfigError> {
let contents = std::fs::read_to_string(path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ConfigError::NotFound { path: path.into() },
_ => ConfigError::Io(e),
})?;
let config: Config = serde_json::from_str(&contents)?;
Ok(config)
}
Anti-Patterns
Wrong: Swallowing errors silently
# BAD -- error is caught and discarded; failures become invisible
try:
result = api.fetch_data()
except Exception:
pass # "it's fine"
Correct: Handle or propagate with context
# GOOD -- error is logged with context and re-raised
try:
result = api.fetch_data()
except ApiError as e:
logger.error("API fetch failed", exc_info=e)
raise ServiceUnavailableError("data source offline") from e
Wrong: Using exceptions for flow control
# BAD -- exception used for normal control flow (expensive, confusing)
def find_item(items, target):
try:
return items[items.index(target)]
except ValueError:
return None
Correct: Use conditional logic for expected cases
# GOOD -- expected case handled with normal control flow
def find_item(items, target):
return target if target in items else None
Wrong: Stringly-typed errors
// BAD -- comparing error strings is brittle and breaks with any message change
if err.Error() == "record not found" {
return defaultValue
}
Correct: Use sentinel errors or error types
// GOOD -- type-safe error checking that survives message changes
if errors.Is(err, sql.ErrNoRows) {
return defaultValue, nil
}
Wrong: Catch-all without re-throw
// BAD -- catches everything including programming bugs
try {
await processOrder(order);
} catch (e) {
console.log("something went wrong");
}
Correct: Catch specific errors, re-throw unknown
// GOOD -- handles known errors, re-throws unexpected ones
try {
await processOrder(order);
} catch (e) {
if (e instanceof ValidationError) {
return { status: 400, body: e.message };
}
if (e instanceof PaymentError) {
await alertOps(e);
return { status: 502, body: "Payment processing failed" };
}
throw e; // unknown error -- let it propagate
}
Wrong: Losing error context when wrapping
# BAD -- original error is lost
try:
data = json.loads(raw)
except json.JSONDecodeError:
raise ValueError("bad input") # original error is gone
Correct: Chain errors with from
# GOOD -- preserves the original error as __cause__
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"bad input at position {e.pos}") from e
Common Pitfalls
- Unhandled Promise rejections in Node.js: Since Node.js 15, unhandled rejections terminate the process. Fix: Always
awaitpromises insidetry/catchor attach.catch(). [src7] - Go: checking err == nil instead of err != nil: Inverted nil check silently proceeds with nil data. Fix: Always verify with
if err != nil { return ..., err }. [src6] - Python: bare except: catches SystemExit and KeyboardInterrupt: Use
except Exception:to avoid catching signals and system exits. Fix: Never use bareexcept:, always specify the exception class. [src3] - Rust: overusing .unwrap(): Causes panics in production. Fix: Use
?for propagation,unwrap_or_default()for safe fallbacks, reserve.unwrap()for tests only. [src1] - Java: catching Exception in library code: Catches RuntimeException subtypes (NPE, ArrayIndexOutOfBounds) that indicate bugs. Fix: Catch the specific checked exception you expect. [src4]
- TypeScript: catch(e) gives unknown type: Since TypeScript 4.4, catch clause variables are
unknownby default. Fix: Useif (e instanceof Error)type guard before accessing.messageor.stack. [src7] - Go: wrapping errors that become part of your API: Using
%wmakes the wrapped error part of your public API contract. Fix: Use%v(not%w) for errors you do not want callers to inspect witherrors.Is. [src2]
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building application-layer error handling (HTTP handlers, CLI, workers) | Handling transient network failures with retry logic | Retry with exponential backoff pattern |
| Defining a custom error hierarchy for a library or service | Modeling state transitions with success/failure branches | State machine implementation |
| Choosing between Result types vs exceptions for a new project | Need HTTP error response format (status codes, error bodies) | API error response design pattern |
| Wrapping errors to add context while preserving the original cause | Building circuit breaker or bulkhead resilience | Circuit breaker pattern |
| Setting up global error handlers at application boundaries | Implementing structured logging and alerting | Logging and monitoring system design |
Important Caveats
- Error handling idioms are language-specific and non-transferable -- what is idiomatic in Rust (Result types) is anti-idiomatic in Python (use exceptions instead)
- The "exceptions vs error values" debate has no universal winner -- each approach has trade-offs in explicitness, performance, and ergonomics that depend on the language ecosystem
- Performance of exceptions varies significantly: Python exceptions are cheap (microseconds), Java exceptions with stack traces are expensive (tens of microseconds) -- in hot paths, this matters
- Error wrapping in Go (%w) creates an API contract -- changing the wrapped error in a future version is a breaking change for callers using errors.Is