Error States and Result Types
Result/Either Type Pattern
Read aloud is unavailable in this browser.
The Workhorse of Functional Error Handling
The Result type (called Either in Haskell and Scala, Result<T, E> in Rust and Swift, or simply a discriminated union in TypeScript) is the cornerstone of value-based error handling. It encapsulates the idea that an operation can produce either a success value or an error value, but never both and never neither.
Unlike exceptions that create invisible control flow branches, a Result type is just a value. It can be stored in variables, passed to functions, returned from functions, collected into containers, and transformed through composition. This makes Result types extraordinarily powerful for building robust, maintainable systems.
This page provides a comprehensive deep-dive into Result types: their structure, core operations, composition patterns, and real-world usage idioms.
By the end of this page, you will understand: the internal structure of Result types; the complete API surface (map, flatMap, fold, etc.); how to compose multiple fallible operations elegantly; error type design strategies; and how to implement or use Result types in your language of choice.
Anatomy of a Result Type
A Result type is a sum type (also called a tagged union or discriminated union) with exactly two variants:
- Success/Ok — Contains the successful result value of type
T - Failure/Err — Contains the error value of type
E
Any Result is always precisely one of these variants, never both, never neither. This exhaustiveness is what makes Result types so powerful—the type system guarantees you handle both cases.
Let's look at how this is expressed in different type systems:
123456789101112131415161718192021
// Rust's standard library Result// Defined in std::resultpub enum Result<T, E> { Ok(T), // Success variant, contains value of type T Err(E), // Error variant, contains error of type E} // Usage examplefn parse_port(s: &str) -> Result<u16, ParseIntError> { s.parse::<u16>() // Returns Result<u16, ParseIntError>} fn main() { let result = parse_port("8080"); // Pattern matching ensures both cases are handled match result { Ok(port) => println!("Port: {}", port), Err(e) => println!("Invalid port: {}", e), }}Haskell calls this 'Either' because the value is either Left or Right. By convention, Left holds errors and Right holds success (a pun: 'right' means correct). Rust's explicit Ok/Err naming is clearer for error handling. Both represent the same concept: a value that's one thing OR another.
Core Operations: The Result API
Result types derive their power from a rich set of operations that allow transformation, combination, and extraction of values. These operations fall into several categories:
Inspection — Determine which variant you have Transformation — Convert the inner value(s) Combination — Chain multiple Results together Extraction — Get the inner value out (with care)
Let's explore each category:
Inspection methods let you query the state of a Result without extracting its value:
1234567891011121314151617
let success: Result<i32, &str> = Ok(42);let failure: Result<i32, &str> = Err("oops"); // is_ok() / is_err() - returns boolassert!(success.is_ok());assert!(!success.is_err());assert!(failure.is_err());assert!(!failure.is_ok()); // is_ok_and() / is_err_and() - predicate with valuelet result: Result<i32, &str> = Ok(42);assert!(result.is_ok_and(|x| x > 40)); // trueassert!(!result.is_ok_and(|x| x > 50)); // false // ok() / err() - convert to Optionlet maybe_value: Option<i32> = success.ok(); // Some(42)let maybe_error: Option<&str> = success.err(); // Noneunwrap() and expect() are useful for prototyping and tests, but they panic on errors. In production code, prefer match, map, or the ? operator for proper error handling. A panic in a server can kill the entire process.
Composing Multiple Results
Real applications often need to combine multiple operations that each return Result. There are several composition strategies depending on what you need:
Sequential composition — Each step depends on the previous Parallel composition — Independent operations combined Error accumulation — Collect all errors, not just the first
Let's examine each pattern:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Sequential composition with ?fn create_order(request: OrderRequest) -> Result<Order, OrderError> { let user = find_user(&request.user_id)?; // Step 1 let items = validate_items(&request.items)?; // Step 2 (depends on Step 1? No) let address = validate_address(&request.address)?; // Step 3 let payment = process_payment(&user, &items)?; // Step 4 (depends on 1 & 2) let order = save_order(&user, &items, &address, &payment)?; // Final Ok(order)} // Parallel composition - combine independent Results// Using the zip patternfn validate_registration( email: &str, password: &str, username: &str,) -> Result<RegistrationData, ValidationError> { let email = validate_email(email)?; let password = validate_password(password)?; let username = validate_username(username)?; Ok(RegistrationData { email, password, username })} // Collecting multiple Results into Result<Vec<T>, E>fn parse_all(inputs: &[&str]) -> Result<Vec<i32>, ParseIntError> { inputs.iter() .map(|s| s.parse::<i32>()) // Iterator<Item = Result<i32, _>> .collect() // Result<Vec<i32>, _>} // If ANY parse fails, the whole thing returns Errlet results = parse_all(&["1", "2", "invalid", "4"]);// Returns: Err(ParseIntError { ... }) let results = parse_all(&["1", "2", "3", "4"]);// Returns: Ok([1, 2, 3, 4]) // Error accumulation - collect ALL errors (requires a collection type)fn validate_all(form: &Form) -> Result<ValidatedForm, Vec<ValidationError>> { let mut errors = Vec::new(); let email = match validate_email(&form.email) { Ok(e) => Some(e), Err(e) => { errors.push(e); None } }; let password = match validate_password(&form.password) { Ok(p) => Some(p), Err(e) => { errors.push(e); None } }; if errors.is_empty() { Ok(ValidatedForm { email: email.unwrap(), password: password.unwrap(), }) } else { Err(errors) }}Standard Result composition 'fails fast' — it returns the first error encountered. For form validation where you want to show all errors at once, you need error accumulation. Libraries like fp-ts provide Validation types specifically for this use case.
Error Type Design Strategies
The E in Result<T, E> is just as important as the T. Well-designed error types make error handling precise, informative, and type-safe. There are several strategies for designing error types:
- •Specific — Distinguish between different error cases
- •Informative — Carry enough context to handle or report
- •Composable — Can be wrapped or converted between layers
- •Display-friendly — Can be formatted for logs and users
- •Debuggable — Include source location, cause chain
- •String-only errors — No structure, can't pattern match
- •Overly generic — Result<T, Error> loses type info
- •Missing context — Errors without enough info to debug
- •Leaking internals — Exposing implementation errors to callers
- •Too many variants — Dozens of cases become unmanageable
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Strategy 1: Enum for domain-specific errors#[derive(Debug, thiserror::Error)]pub enum OrderError { #[error("User not found: {user_id}")] UserNotFound { user_id: UserId }, #[error("Insufficient inventory for item {item_id}: requested {requested}, available {available}")] InsufficientInventory { item_id: ItemId, requested: u32, available: u32, }, #[error("Payment declined: {reason}")] PaymentDeclined { reason: String }, #[error("Database error")] Database(#[source] sqlx::Error),} // Strategy 2: Hierarchical errors for layered architecture#[derive(Debug, thiserror::Error)]pub enum RepositoryError { #[error("Entity not found")] NotFound, #[error("Constraint violation: {0}")] Constraint(String), #[error("Connection error")] Connection(#[source] sqlx::Error),} #[derive(Debug, thiserror::Error)]pub enum ServiceError { #[error("Repository error")] Repository(#[from] RepositoryError), // Automatic conversion #[error("Validation failed: {0}")] Validation(String), #[error("External service error")] External(#[source] reqwest::Error),} // Strategy 3: Context wrapping with anyhowuse anyhow::{Context, Result}; fn load_config() -> Result<Config> { let path = get_config_path() .context("Failed to determine config path")?; let contents = std::fs::read_to_string(&path) .with_context(|| format!("Failed to read config from {:?}", path))?; let config: Config = serde_json::from_str(&contents) .context("Failed to parse config JSON")?; Ok(config)}Converting and Wrapping Errors
In layered architectures, errors from lower layers need to be converted to appropriate error types for higher layers. This prevents implementation details from leaking and maintains proper abstraction boundaries.
Key principles:
-
Don't expose internal errors to API consumers — A database constraint violation should become a domain validation error, not expose the SQL schema.
-
Add context as errors propagate — Each layer can add relevant information.
-
Preserve cause chains for debugging — The original error should be accessible for logging while presenting a clean error to callers.
-
Convert at layer boundaries — Services should return service errors, repositories return repository errors, etc.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Repository layerpub enum RepositoryError { NotFound, Duplicate, Connection(sqlx::Error),} impl UserRepository { pub fn find_by_email(&self, email: &str) -> Result<User, RepositoryError> { match self.db.query_one(...) { Ok(row) => Ok(User::from_row(row)), Err(sqlx::Error::RowNotFound) => Err(RepositoryError::NotFound), Err(e) => Err(RepositoryError::Connection(e)), } }} // Service layerpub enum UserServiceError { UserNotFound { email: String }, EmailAlreadyRegistered { email: String }, Repository(RepositoryError), // Wrapped for debugging} // Implement From for automatic conversionimpl From<RepositoryError> for UserServiceError { fn from(e: RepositoryError) -> Self { UserServiceError::Repository(e) }} impl UserService { pub fn get_user_by_email(&self, email: &str) -> Result<User, UserServiceError> { self.repository .find_by_email(email) .map_err(|e| match e { RepositoryError::NotFound => { UserServiceError::UserNotFound { email: email.to_string() } } other => other.into(), // Use From implementation }) }} // API layer - convert to HTTP responseimpl From<UserServiceError> for HttpResponse { fn from(e: UserServiceError) -> Self { match e { UserServiceError::UserNotFound { email } => { HttpResponse::NotFound(format!("No user with email: {}", email)) } UserServiceError::EmailAlreadyRegistered { email } => { HttpResponse::Conflict(format!("Email already registered: {}", email)) } UserServiceError::Repository(inner) => { // Log the internal error, return generic message log::error!("Repository error: {:?}", inner); HttpResponse::InternalError("An unexpected error occurred") } } }}In Rust, result? is essentially match result { Ok(v) => v, Err(e) => return Err(e.into()) }. The .into() means if you have impl From<LowerError> for HigherError, errors convert automatically. This makes error propagation with conversion remarkably ergonomic.
Railway-Oriented Programming
Railway-Oriented Programming (ROP) is a metaphor for thinking about Result-based composition. Imagine two parallel railway tracks:
- Success track (green) — Normal execution continues
- Failure track (red) — Error state propagates
Each function is like a railway switch that can:
- Keep the train on the success track
- Switch it to the failure track
Once on the failure track, subsequent success-track operations are bypassed (the error propagates). Only explicit error handling can switch back.
This mental model makes complex flows easier to visualize and reason about:
1234567891011121314151617181920212223242526272829
// Each function is a "railway switch"fn validate_email(email: &str) -> Result<ValidEmail, ValidationError>;fn validate_password(password: &str) -> Result<ValidPassword, ValidationError>;fn create_user(email: ValidEmail, pass: ValidPassword) -> Result<User, CreateError>;fn send_welcome(user: &User) -> Result<(), EmailError>; // The railway: chain of switchesfn register_user(email: &str, password: &str) -> Result<User, RegistrationError> { // Enter the railway let email = validate_email(email)?; // Switch 1: might go to failure track let password = validate_password(password)?; // Switch 2 let user = create_user(email, password)?; // Switch 3 // Non-critical operation - don't derail for email failure if let Err(e) = send_welcome(&user) { warn!("Welcome email failed: {:?}", e); } Ok(user) // Still on success track} // Visualizing the flow://// email ──────► validate_email ──────► validate_password ──────► create_user ─────► Ok(user)// │ │ │// ▼ ValidationError ▼ ValidationError ▼ CreateError// Err◄─────────────────────Err◄────────────────────Err──────────► Err(error)//// Once on the Err track, subsequent Ok operations are skippedRailway-Oriented Programming isn't just a cute metaphor — it's a powerful tool for designing error-handling flows. When reviewing code, visualize the two tracks and ask: 'At this switch, what can cause derailment? Is it appropriate to derail here, or should this be a tunnel (operation that can't fail) or a dead end (log but don't derail)?'
Async/Await and Result Types
When combining Result types with async/await, you're dealing with two layers of wrapping: the async operation (Promise/Future) and the Result. This requires careful handling to avoid nested unwrapping.
123456789101112131415161718192021222324252627282930313233343536373839404142
// In Rust, async functions often return Resultasync fn fetch_user(id: UserId) -> Result<User, FetchError> { let response = reqwest::get(&format!("/users/{}", id)) .await // Await the Future .map_err(FetchError::Network)?; // Handle network error let user: User = response .json() .await // Await JSON parsing .map_err(FetchError::Parse)?; // Handle parse error Ok(user)} // Combining multiple async Resultsasync fn get_user_with_orders(id: UserId) -> Result<UserWithOrders, Error> { let user = fetch_user(id).await?; let orders = fetch_orders(id).await?; Ok(UserWithOrders { user, orders })} // Parallel execution with join! and Resultuse futures::future::try_join; async fn get_user_and_orders_parallel(id: UserId) -> Result<UserWithOrders, Error> { let (user, orders) = try_join!( fetch_user(id), fetch_orders(id), )?; // Returns first error Ok(UserWithOrders { user, orders })} // Collecting async Resultsasync fn fetch_all_users(ids: &[UserId]) -> Result<Vec<User>, Error> { let futures: Vec<_> = ids.iter() .map(|id| fetch_user(*id)) .collect(); futures::future::try_join_all(futures).await}With Promise<Result<T, E>>, you await the Promise first, then check the Result. Some libraries provide ResultPromise types that combine these layers, offering methods like mapAsync that handle both at once. In Rust, the type is often impl Future<Output = Result<T, E>>, which the ? operator handles elegantly.
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.