Loading learning content...
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.
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:
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"); } }}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:
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") } } }}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 most important distinction between these models is semantic — what kind of failure are we representing?
Exceptional failures are situations that:
Expected failures are situations that:
Mapping these semantics to mechanisms:
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.
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 from from but never credited to to. 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 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(_)))); }}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.
The distinction between error states and exceptions is not merely syntactic preference—it's a fundamental design choice with far-reaching consequences for code clarity, safety, and maintainability.
What's next:
Now that we understand the philosophical and practical distinction between error states and exceptions, we'll dive deep into the Result/Either type pattern — the primary tool for representing success or failure as explicit values. You'll learn the full API surface of Result types, composition patterns, and idiomatic usage across different languages.
You now understand the fundamental distinction between exception-based and value-based error handling. This foundation prepares you for mastering Result types, Option types, and the sophisticated composition patterns that make functional error handling so powerful.