Error States and Result Types
Error States vs Exceptions
Read aloud is unavailable in this browser.
Two Philosophies of Failure
Every software system encounters failures. Network connections drop, files don't exist, users provide invalid input, and resources become exhausted. How we represent and handle these failures is one of the most consequential design decisions in any codebase.
There are fundamentally two approaches:
-
Exceptions — Failures are signaled by throwing/raising an exception, which disrupts normal control flow and unwinds the call stack until a handler catches it.
-
Error States — Failures are represented as explicit values that functions return. The caller must inspect the result to determine if the operation succeeded.
Neither approach is universally superior. Each embodies different philosophies about how software should handle failure, and each excels in different contexts. Understanding both deeply is essential for designing robust systems.
By the end of this page, you will understand the fundamental tradeoffs between exceptions and error states, recognize when each approach is appropriate, and appreciate why modern languages increasingly offer both paradigms. You'll develop intuition for choosing the right error representation based on failure semantics and system requirements.
The Exception Model
Exceptions emerged in the 1960s and became mainstream through languages like C++, Java, and Python. The exception model treats failures as exceptional events that disrupt normal program flow.
How exceptions work:
- When a failure occurs, the function throws an exception object
- Normal execution immediately stops
- The runtime searches up the call stack for a matching catch block
- If found, execution resumes in the catch block
- If not found, the program terminates (usually with a stack trace)
This mechanism provides a form of non-local control flow — execution can jump from deep in the call stack directly to a handler many levels up.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// The exception model in actionpublic class UserService { private UserRepository repository; private EmailService emailService; public User createUser(String email, String password) { // Each of these methods might throw exceptions validateEmail(email); // throws InvalidEmailException validatePassword(password); // throws WeakPasswordException if (repository.existsByEmail(email)) { throw new EmailAlreadyExistsException(email); } User user = new User(email, hashPassword(password)); repository.save(user); // throws DatabaseException emailService.sendWelcome(user); // throws EmailServiceException return user; }} // Caller must handle or propagate exceptionspublic class UserController { private UserService userService; public Response handleRegistration(Request request) { try { User user = userService.createUser( request.getEmail(), request.getPassword() ); return Response.created(user); } catch (InvalidEmailException e) { return Response.badRequest("Invalid email format"); } catch (WeakPasswordException e) { return Response.badRequest("Password too weak"); } catch (EmailAlreadyExistsException e) { return Response.conflict("Email already registered"); } catch (DatabaseException e) { log.error("Database error", e); return Response.serverError("Please try again later"); } }}- •Clean happy path — The normal case reads clearly without interleaved error handling; success flow is uninterrupted and highly readable.
- •Automatic propagation — Exceptions propagate up the call stack automatically; intermediate functions don't need to handle errors they can't meaningfully address.
- •Rich diagnostic information — Exception objects carry stack traces, messages, and can form chains, providing excellent debugging context.
- •Guaranteed cleanup — Finally blocks (or destructors) execute regardless of how a block exits, ensuring resource cleanup.
- •Handles truly exceptional cases well — For programming errors or irrecoverable conditions, exceptions provide appropriate severity signaling.
The Error State Model
The error state model treats failures as expected possibilities that should be represented explicitly in a function's return type. Rather than disrupting control flow, operations return values that might represent either success or failure.
How error states work:
- Functions return a composite type that can represent success or failure
- The caller must inspect this return value to access the result
- Error handling is local — each call site deals with its immediate result
- The type system enforces that failure cases cannot be accidentally ignored
- Composition is achieved through explicit transformation functions
This approach originated in functional programming languages (ML, Haskell) and has spread to imperative languages through types like Rust's Result<T, E>, Kotlin's Result<T>, and Swift's Result<Success, Failure>.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// The error state model in Rustuse thiserror::Error; #[derive(Error, Debug)]pub enum CreateUserError { #[error("Invalid email format: {0}")] InvalidEmail(String), #[error("Password too weak: {0}")] WeakPassword(String), #[error("Email already exists: {0}")] EmailExists(String), #[error("Database error: {0}")] Database(#[from] DatabaseError), #[error("Email service error: {0}")] EmailService(#[from] EmailServiceError),} impl UserService { pub fn create_user( &self, email: &str, password: &str ) -> Result<User, CreateUserError> { // Each operation returns a Result that must be handled self.validate_email(email)?; // ? propagates errors self.validate_password(password)?; if self.repository.exists_by_email(email)? { return Err(CreateUserError::EmailExists(email.into())); } let user = User::new(email, self.hash_password(password)); self.repository.save(&user)?; self.email_service.send_welcome(&user)?; Ok(user) }} // Caller must handle the Resultimpl UserController { pub fn handle_registration(&self, request: Request) -> Response { match self.user_service.create_user(&request.email, &request.password) { Ok(user) => Response::created(user), Err(CreateUserError::InvalidEmail(msg)) => { Response::bad_request(&format!("Invalid email: {}", msg)) } Err(CreateUserError::WeakPassword(msg)) => { Response::bad_request(&format!("Weak password: {}", msg)) } Err(CreateUserError::EmailExists(_)) => { Response::conflict("Email already registered") } Err(CreateUserError::Database(e)) => { log::error!("Database error: {:?}", e); Response::server_error("Please try again later") } Err(CreateUserError::EmailService(e)) => { log::warn!("Email service error: {:?}", e); // User created but email failed - might still return success Response::created_with_warning("Welcome email failed") } } }}- •Explicit error handling — Failure cases are visible in the type system; the compiler ensures you handle them, eliminating forgotten error paths.
- •Local reasoning — Each function call site handles its errors immediately; you can understand error handling without tracing call stacks.
- •No hidden control flow — Execution follows visible paths; there are no secret jumps or stack unwinding that might bypass important code.
- •Composable operations — Result types support map, flatMap/andThen, and other combinators for elegant error transformation chains.
- •Precise error types — The type system documents exactly which errors can occur, serving as self-documenting, verifiable contracts.
Comparing the Two Models
The choice between exceptions and error states isn't merely syntactic—it reflects fundamentally different views about what failures mean and how they should be handled. Let's examine the key differences:
| Dimension | Exceptions | Error States (Result Types) |
|---|---|---|
| Visibility | Implicit — not visible in function signatures (except Java's checked exceptions) | Explicit — failure possibility encoded in the return type |
| Control Flow | Non-local — can jump multiple stack frames | Local — errors handled at each call site |
| Compiler Enforcement | Generally none (except checked exceptions) | Full enforcement — must handle before accessing value |
| Happy Path Clarity | Very clean — no error handling code inline | More verbose — error handling interleaved (though mitigated by combinators) |
| Composition | Try-catch nesting can become awkward | Natural with map/flatMap/andThen operators |
| Performance | Zero-cost happy path, expensive on throw | Slight overhead always (result wrapper), no throw cost |
| Concurrent Code | Exceptions don't cross thread boundaries easily | Error states are just values — cross boundaries naturally |
| Recoverable vs Fatal | Often conflated — same mechanism for both | Usually separate — Result for recoverable, panic for fatal |
In most applications, exception throwing is rare enough that performance doesn't matter. However, in hot paths where failures are common (parsing user input, network operations), the cost of throwing can become significant. Error states have consistent, predictable performance regardless of success or failure.
The Semantic Distinction: Expected vs Exceptional
The most important distinction between these models is semantic — what kind of failure are we representing?
Exceptional failures are situations that:
- Are unexpected in normal operation
- Represent programming errors or environmental failures
- Often cannot be meaningfully recovered from
- Require immediate attention
Expected failures are situations that:
- Are anticipated possibilities in normal operation
- Represent valid outcomes of an operation (just not the happy path)
- Can and should be handled gracefully
- Are part of the function's contract
Mapping these semantics to mechanisms:
- •Programming errors — Null pointer dereference, index out of bounds, assertion failures
- •Invariant violations — System state corruption, impossible conditions reached
- •Unrecoverable resource failures — Out of memory, disk full, system call failures
- •Configuration errors — Missing required settings, malformed config files at startup
- •Fatal security violations — Authentication bypass attempts, tampered data
- •User input validation — Invalid email format, weak password, missing fields
- •Business rule violations — Insufficient funds, expired subscription, quota exceeded
- •Expected external failures — Network timeout, service unavailable, rate limited
- •Not-found conditions — User doesn't exist, record deleted, cache miss
- •Partial success — Some items processed, some failed
Java attempted to get the best of both worlds with checked exceptions — exceptions that must be declared and handled. In practice, this led to verbose code, exception swallowing, and the catch-and-rethrow-as-runtime pattern. Modern languages learned from this: use real exceptions for exceptional cases, and explicit value types for expected failures.
The Hidden Control Flow Problem
One of the most significant criticisms of exceptions is that they create hidden control flow. When reading code, you cannot easily determine which statements might transfer control elsewhere.
Consider this seemingly innocent function:
123456789101112131415161718
public class TransferService { public void transferMoney(Account from, Account to, Money amount) { // Acquire locks in consistent order to prevent deadlock Lock fromLock = lockManager.acquire(from); Lock toLock = lockManager.acquire(to); try { from.debit(amount); // Can this throw? to.credit(amount); // Can this throw? auditLog.record(from, to, amount); // Can this throw? notificationService.notify(from.getOwner(), "Transfer complete"); } finally { toLock.release(); fromLock.release(); } }}The hidden dangers:
-
If
from.debit(amount)throws, the money wasn't debited but locks are released correctly. ✓ -
If
to.credit(amount)throws, the money was debited fromfrombut never credited toto. The system is in an inconsistent state. ✗ -
If
auditLog.record()throws, the transfer succeeded but wasn't logged. Might be okay, might violate compliance. ? -
If
notificationService.notify()throws, the transfer succeeded but user wasn't notified. Probably fine. ?
Without examining every method's implementation (and its transitive dependencies), you cannot know which lines might throw. This makes reasoning about correctness extremely difficult.
1234567891011121314151617181920212223242526272829303132333435363738394041
impl TransferService { pub fn transfer_money( &self, from: &mut Account, to: &mut Account, amount: Money, ) -> Result<TransferReceipt, TransferError> { // With Result types, every fallible operation is visible let _from_lock = self.lock_manager.acquire(from)?; let _to_lock = self.lock_manager.acquire(to)?; // Explicit: this can fail with InsufficientFunds from.debit(amount)?; // If credit fails, we need to compensate if let Err(e) = to.credit(amount) { // Rollback the debit from.credit(amount).expect("Rollback must succeed"); return Err(e.into()); } // Audit log failure is logged but doesn't fail the transfer if let Err(e) = self.audit_log.record(from, to, amount) { log::error!("Audit log failed: {:?}", e); // Continue - transfer succeeded } // Notification failure is just a warning if let Err(e) = self.notification_service.notify( from.get_owner(), "Transfer complete" ) { log::warn!("Notification failed: {:?}", e); } Ok(TransferReceipt::new(from, to, amount)) }} // Reading this code, every possible failure point is visible.// The handling strategy for each is explicit and reviewable.Error states enable 'local reasoning' — you can understand what a piece of code does by looking at it, without needing to chase exception specifications through the entire call graph. This property is especially valuable in large codebases where one person can't know everything.
Error State Benefits for Testing
Error states as explicit values provide significant testing advantages over exceptions:
1. Error cases are first-class test subjects
With error states, you can write standard unit tests that assert on error values:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
#[cfg(test)]mod tests { use super::*; #[test] fn create_user_with_invalid_email_returns_validation_error() { let service = UserService::new(mock_repository(), mock_email()); let result = service.create_user("not-an-email", "ValidPass123!"); // Error is a value we can assert on assert!(result.is_err()); match result.unwrap_err() { CreateUserError::InvalidEmail(msg) => { assert!(msg.contains("@")); } other => panic!("Expected InvalidEmail, got {:?}", other), } } #[test] fn create_user_with_existing_email_returns_conflict() { let mut mock_repo = mock_repository(); mock_repo.expect_exists_by_email() .returning(|_| Ok(true)); // Email exists let service = UserService::new(mock_repo, mock_email()); let result = service.create_user("taken@example.com", "ValidPass123!"); assert!(matches!(result, Err(CreateUserError::EmailExists(_)))); } #[test] fn create_user_database_failure_is_propagated() { let mut mock_repo = mock_repository(); mock_repo.expect_save() .returning(|_| Err(DatabaseError::ConnectionLost)); let service = UserService::new(mock_repo, mock_email()); let result = service.create_user("new@example.com", "ValidPass123!"); assert!(matches!(result, Err(CreateUserError::Database(_)))); }}Testing Advantages of Error States
- •No special exception handling in tests — Tests are simple assertions on return values, not try-catch blocks expecting specific exception types.
- •Error values are inspectable — You can assert on error messages, codes, and nested data without string-matching exception messages.
- •Composable test utilities — Helper functions can return Result types, making test setup easier to compose.
- •Property-based testing friendly — Error states work naturally with property-based testing frameworks.
- •Better IDE support — Type-aware IDEs can autocomplete error cases in test assertions.
Language Support Landscape
Different programming languages take different stances on error handling. Understanding the landscape helps you apply appropriate patterns in your chosen language:
| Language | Primary Mechanism | Secondary/Alternative | Notes |
|---|---|---|---|
| Rust | Result<T, E> | panic! for fatal errors | Result is the default; exceptions don't exist |
| Haskell | Either a b, Maybe a | Exceptions possible but discouraged | Strong functional tradition with monadic error handling |
| Go | Multiple returns (val, error) | panic/recover for truly exceptional cases | Explicit but verbose; no generics until recently |
| Kotlin | Exceptions (default) | Result<T>, sealed classes | Result gaining adoption; Arrow library for functional patterns |
| Swift | Exceptions (throws) | Optional<T>, Result<Success, Failure> | Swift 5 added Result; throws requires explicit handling |
| TypeScript | Exceptions (from JS) | Discriminated unions, Result types (library) | neverthrow, fp-ts libraries provide Result types |
| Java | Checked + Runtime Exceptions | Optional<T>, Vavr's Either/Try | Checked exceptions often avoided; Optional for nulls |
| Python | Exceptions | returns library, custom implementations | Optional type hints don't prevent runtime errors |
| C# | Exceptions | Nullable reference types, OneOf library | .NET ecosystem favors exceptions; functional libraries exist |
Rust's success with Result<T, E> as the primary error handling mechanism has influenced many other languages. Kotlin, Swift, and even Java are adding or improving result type support. The 'make errors unignorable' philosophy is gaining traction across the industry.
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.