Error Handling Strategies by Language: Exceptions, Result Types, and Error Values

Type: Software Reference Confidence: 0.92 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

LanguageMechanismRecoverable ErrorsFatal ErrorsCustom ErrorsBest Practice
Pythontry/exceptCatch specific exceptionssys.exit(), unhandled exceptionsSubclass ExceptionCatch narrow, raise with context (from e)
TypeScript/JStry/catch + Promisecatch block, .catch()process.exit(), unhandled rejectionExtend Error classUse cause property (ES2022), typed error guards
GoError values (error interface)if err != nil returnpanic()/log.Fatal()Implement error interfaceWrap with fmt.Errorf("%w", err), use errors.Is/As
RustResult<T, E> / Option<T>match, ? operatorpanic!(), .unwrap()Implement std::error::ErrorUse thiserror (libraries) / anyhow (applications)
JavaChecked + unchecked exceptionstry/catchSystem.exit(), Error subclassesExtend Exception or RuntimeExceptionPrefer unchecked for recoverable; checked for cross-boundary
C#Exceptions (all unchecked)try/catch/finallyEnvironment.FailFast()Extend ExceptionUse 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

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Building application-layer error handling (HTTP handlers, CLI, workers)Handling transient network failures with retry logicRetry with exponential backoff pattern
Defining a custom error hierarchy for a library or serviceModeling state transitions with success/failure branchesState machine implementation
Choosing between Result types vs exceptions for a new projectNeed HTTP error response format (status codes, error bodies)API error response design pattern
Wrapping errors to add context while preserving the original causeBuilding circuit breaker or bulkhead resilienceCircuit breaker pattern
Setting up global error handlers at application boundariesImplementing structured logging and alertingLogging and monitoring system design

Important Caveats

Related Units