Error States and Result Types
When to Use Result Types
Read aloud is unavailable in this browser.
Making the Right Choice
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.
The Decision Framework
Choosing an error handling approach requires answering several key questions about the operation and its context. Here's a systematic framework:
- •Is failure expected or exceptional? — Expected failures (validation, not-found, business rule violations) → Result types. Exceptional failures (invariant violations, programming errors) → Exceptions.
- •Can the caller meaningfully recover? — If recovery is possible and expected → Result types. If the only option is 'abort and report' → Exceptions.
- •Does absence mean 'nothing' or 'failed'? — No value without failure (lookup miss, empty list) → Option. No value because something went wrong → Result.
- •Are there multiple distinct failure modes? — Yes, caller needs to distinguish → Result with error enum. Single failure mode → Simpler handling might suffice.
- •What does the language/ecosystem expect? — Follow established conventions unless there's strong reason to deviate.
- •What is the call site context? — Hot path with frequent failures → Result (avoid exception overhead). Rare failures → Either approach works.
// 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.
API Design Considerations
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.
Language Ecosystem Conventions
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 appropriateStrategies for Mixed Codebases
Real-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:
- •Convert at boundaries — Wrap exception-throwing libraries in Result-returning wrappers at the integration point, not scattered throughout your code.
- •Use adapter functions — Create
tryCatch/runCatchinghelpers that convert exceptions to Results in a consistent way. - •Establish conventions — Document which layer uses which approach; enforce consistency within each layer.
- •Handle both in controllers — At application boundaries (HTTP handlers, CLI entrypoints), handle both Results and exceptions, converting to responses.
- •Gradual migration — When migrating, convert module by module; maintain converters at module boundaries.
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.
Performance Considerations
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?
- Parsing user input — Input validation often fails; using exceptions for invalid input can hurt throughput under malicious or malformed loads.
- Network operations — Timeouts and failures are common; exception overhead can compound with high request volumes.
- Tight loops — Any operation called millions of times per second; even small overheads add up.
- Game loops — Frame budget is tight; unpredictable exception costs can cause stuttering.
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.
Real-World Case Studies
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()returnsResult<String, io::Error>because file operations can fail for many reasons (permissions, not found, read errors)str::parse()returnsResult<T, T::Err>because parsing can fail on invalid inputVec::get()returnsOption<&T>because accessing an index that doesn't exist isn't an errorVec::first()returnsOption<&T>because an empty vector is validVec::swap()panics on out-of-bounds because the caller controls the indices — it's a programming error
Key lesson: Use Result when failure has external causes; use Option when absence is expected; panic only on programming errors.
Practical Guidelines Summary
Here's a consolidated reference for making error handling decisions in everyday development:
- •Failure is an expected outcome of the operation
- •The caller can and should recover from the failure
- •There are multiple error types the caller needs to distinguish
- •You're in a hot path where failures are frequent
- •You're writing library code where callers should decide how to handle errors
- •The operation crosses thread/async boundaries
- •You want type-safe exhaustive error handling
- •The failure represents a programming error (invariant violation)
- •The condition is truly exceptional and rare
- •The caller cannot meaningfully recover — crash is appropriate
- •You're following framework conventions that expect exceptions
- •The error should propagate far up the stack with minimal handling
- •You're in a language/ecosystem where exceptions are idiomatic
- •The failure represents infrastructure breakdown (DB down, network unavailable)
- •Absence is valid and doesn't indicate failure (lookup miss, empty collection, optional field)
- •There's only one kind of 'nothing' — no need to distinguish error cases
- •You're returning from a lookup or search operation
- •The operation cannot fail — it just might not find anything
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.