Loading content...
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.
A Result type is a sum type (also called a tagged union or discriminated union) with exactly two variants:
TEAny 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.
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.
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.
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:
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)}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 (ROP) is a metaphor for thinking about Result-based composition. Imagine two parallel railway tracks:
Each function is like a railway switch that can:
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)?'
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.
The Result/Either pattern is the primary tool for explicit, type-safe error handling. When used well, it makes error cases impossible to ignore while maintaining clean, composable code.
What's next:
We've covered Result types for operations that can succeed or fail with a reason. But what about operations that can succeed or simply have no value — without implying something went wrong? That's where the Option/Maybe type pattern comes in, which we'll explore in the next page.
You now have a deep understanding of Result/Either types — the workhorse of functional error handling. You can create, compose, transform, and extract Results, design appropriate error types, and apply railway-oriented thinking to complex flows.