Loading content...
You now understand exceptions, Result types, and Option types. Each has strengths; each has appropriate use cases. But in practice, choosing between them isn't always obvious.
When should a function throw an exception versus return a Result?
When should an API use nullable types versus Option wrappers?
How do you handle a codebase that mixes both approaches?
This page provides a comprehensive decision framework for choosing the right error handling strategy. We'll cover decision criteria, API design considerations, ecosystem factors, migration strategies, and real-world case studies that illustrate these principles in action.
By the end of this page, you will have: a clear decision framework for choosing error handling approaches; understanding of how to design APIs with appropriate error semantics; strategies for working in mixed codebases; knowledge of language ecosystem conventions; and practical guidelines for common scenarios.
Choosing an error handling approach requires answering several key questions about the operation and its context. Here's a systematic framework:
// Decision Flowchart for Error Handling START: Operation might fail or return nothing ├── Q1: Is this a programming error or invariant violation?│ └── YES → throw Exception (or panic)│ Examples: null dereference, index out of bounds, assertion failure│├── Q2: Is failure expected in normal operation?│ ├── YES → Continue to Q3│ └── NO (truly exceptional) → throw Exception│ Examples: out of memory, disk full, network unreachable (sometimes)│├── Q3: Does absence imply failure or just "nothing"?│ ├── Just "nothing" (no error implied) → return Option<T>│ │ Examples: find in map, first element, parent of root│ └── Failure (something went wrong) → Continue to Q4│├── Q4: Are there multiple distinct error cases?│ ├── YES → return Result<T, ErrorEnum>│ │ Examples: validation errors, NOT_FOUND vs DB_ERROR│ └── NO (single failure mode) → return Result<T, E> or simple error│└── Q5: What does your ecosystem expect? ├── Rust → Always Result/Option (no exceptions) ├── Go → Always (value, error) tuple ├── Kotlin/Swift → Nullable types with sealed classes for complex errors ├── TypeScript → Consider: strict null checks + Result unions └── Java/Python → Exceptions are idiomatic, but Result types gaining tractionAlways think from the caller's perspective: What does the caller expect? What can the caller do if this fails? An operation that's 'exceptional' from one perspective might be 'expected' from another. A database connection failure is exceptional for an HTTP request handler, but expected for a connection pool that retries.
When designing public APIs (libraries, services, modules), your error handling choice becomes a contract with consumers. These decisions are harder to change later, so thoughtful upfront design matters.
| Scenario | Recommended Approach | Rationale |
|---|---|---|
| Validation functions | Result<ValidatedT, ValidationErrors> | Callers expect validation to fail often; they need to know what failed |
| Repository/DAO methods | Result<T, RepositoryError> | Distinguish NOT_FOUND from connection errors vs constraint violations |
| HTTP client calls | Result<Response, HttpError> | Network failures are expected; callers need status codes, timeouts, etc. |
| Configuration loading | Result<Config, ConfigError> | Startup failures should be clear; caller may want to fall back |
| Collection lookups | Option<T> | Not finding an element isn't an error; it's an expected outcome |
| Parsing/conversion | Result<T, ParseError> | Invalid input is expected; caller needs to know what was wrong |
| Resource acquisition | Result<Resource, AcquisitionError> | Caller may retry, use fallback, or report; needs context |
| Internal assertions | Exception/panic | Programming errors should crash loudly, not be handled |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Well-designed API with appropriate error types // Validation: Result with detailed errorspub fn validate_email(input: &str) -> Result<Email, EmailValidationError> { if input.is_empty() { return Err(EmailValidationError::Empty); } if !input.contains('@') { return Err(EmailValidationError::MissingAt); } // ... more validation Ok(Email::new_unchecked(input))} // Repository: Result distinguishing error typespub trait UserRepository { fn find_by_id(&self, id: UserId) -> Result<User, FindError>; fn find_by_email(&self, email: &Email) -> Result<Option<User>, DbError>; // ^^^^^^^^^^^^^^^^ // Note: Option inside Result! This means: // - Ok(Some(user)): Found the user // - Ok(None): No user with that email (not an error) // - Err(e): Database error occurred fn save(&self, user: &User) -> Result<(), SaveError>;} #[derive(Debug, Error)]pub enum FindError { #[error("User not found: {0}")] NotFound(UserId), #[error("Database error")] Database(#[source] sqlx::Error),} // Lookup: Option when absence is normalpub trait Cache<K, V> { fn get(&self, key: &K) -> Option<&V>; // Miss is expected fn insert(&mut self, key: K, value: V) -> Option<V>; // Returns old value} // Parsing: Result with informative errorspub fn parse_duration(s: &str) -> Result<Duration, ParseDurationError> { // Returns Err with context about what was wrong} // Internal function: panic on invariant violationfn process_validated_data(data: &ValidatedData) { // This function's contract requires data to be pre-validated // Panicking on invalid data is appropriate - it's a programming error assert!(data.is_valid(), "invariant violation: data must be valid");}Sometimes you need both: distinguishing 'not found' (a valid outcome) from 'database error' (a failure). Result<Option<T>, E> expresses this: Ok(Some(x)) = found, Ok(None) = not found, Err(e) = error. This is more precise than overloading 'not found' as an error type.
Each programming language has established conventions for error handling. Fighting these conventions creates friction for users of your code. Understanding and following ecosystem norms — while knowing when to deviate — is crucial.
Rust and Go have explicit error handling as their primary paradigm. Exceptions don't exist (Rust's panic is not for recoverable errors; Go's panic is for truly exceptional cases only).
12345678910111213141516171819202122
// Rust Convention: Result<T, E> for all fallible operations // Standard library examples:fn open(path: &Path) -> Result<File, io::Error>fn read_to_string(&mut self) -> Result<String, io::Error>fn parse<F: FromStr>(&self) -> Result<F, F::Err> // The ? operator makes this ergonomic:fn read_config() -> Result<Config, ConfigError> { let file = File::open("config.toml")?; let contents = read_to_string(file)?; let config: Config = toml::from_str(&contents)?; Ok(config)} // panic! is for unrecoverable errors:// - Programming bugs (index out of bounds on internal data)// - Unrecoverable state (corruption, violated invariants)// - When you *want* the program to crash // Avoid panic in library code; let callers decide// Libraries should return Result; applications can panic if appropriateReal-world codebases often mix exception and Result-based error handling, especially during migrations or when integrating with libraries that use different approaches. Here are strategies for managing this complexity:
tryCatch / runCatching helpers that convert exceptions to Results in a consistent way.12345678910111213141516171819202122232425
// Wrapping a panic-prone FFI libraryfn safe_ffi_call(input: &str) -> Result<Output, FfiError> { // std::panic::catch_unwind converts panics to Result std::panic::catch_unwind(|| { unsafe { ffi_library::process(input) } }) .map_err(|_| FfiError::Panic) .and_then(|result| result.map_err(FfiError::Library))} // Wrapping a fallible external crateuse external_crate::Client; struct SafeClient(Client); impl SafeClient { fn fetch(&self, url: &str) -> Result<Response, FetchError> { // External crate might panic on invalid URL self.0.fetch(url).map_err(|e| match e.kind() { external_crate::ErrorKind::Network => FetchError::Network, external_crate::ErrorKind::Timeout => FetchError::Timeout, _ => FetchError::Unknown(e.to_string()), }) }}Create an 'adapter' or 'gateway' layer at the boundary between your domain code and external libraries. This layer converts library-specific error types (exceptions, error codes, whatever) to your domain's error types. Your domain code never sees external error representations.
Error handling mechanisms have different performance characteristics. While this rarely matters, understanding the tradeoffs helps with informed decisions in performance-critical code.
| Mechanism | Happy Path Cost | Error Path Cost | When It Matters |
|---|---|---|---|
| Exceptions | Zero (no overhead) | High (stack unwinding, object creation) | Hot paths with frequent failures |
| Result types | Small (wrapper allocation) | Same as happy path | Never — consistent performance |
| Nullable/Option | None to small | None to small | Rarely significant |
| Error codes (C-style) | Zero | Zero | Ultra-low-level systems |
When does performance matter?
Practical guidance:
For most applications, the performance difference is negligible. Choose based on semantics and code clarity. If profiling shows error handling as a bottleneck, consider Result types for that specific hot path.
Don't choose Result types 'for performance' in normal code. The overhead of exceptions is milliseconds at worst. Choose based on what makes your code clearer and more correct. If you have a proven performance problem, benchmark both approaches in your specific context.
Let's examine how real systems and libraries make error handling decisions, and what we can learn from their approaches.
The Rust standard library provides an excellent case study in Result-based design:
std::fs::read_to_string() returns Result<String, io::Error> because file operations can fail for many reasons (permissions, not found, read errors)str::parse() returns Result<T, T::Err> because parsing can fail on invalid inputVec::get() returns Option<&T> because accessing an index that doesn't exist isn't an errorVec::first() returns Option<&T> because an empty vector is validVec::swap() panics on out-of-bounds because the caller controls the indices — it's a programming errorKey lesson: Use Result when failure has external causes; use Option when absence is expected; panic only on programming errors.
Here's a consolidated reference for making error handling decisions in everyday development:
Error handling is not just a technical choice — it's a communication choice. The mechanism you select tells callers what to expect and how to respond. Choose based on semantics, not syntax preference.
Module Complete:
You've now mastered the full landscape of error states and result types:
These patterns are foundational for writing robust, maintainable software. Apply them consistently, and your code will be clearer, safer, and more resilient to the inevitable failures that every system encounters.
You now have a complete understanding of when and how to use Result types, Option types, and exceptions. You can design APIs with appropriate error semantics, work effectively in mixed codebases, and apply these patterns idiomatically across different programming languages and ecosystems.