Skip to content
Home
Error Handling in FP: Either, Try, and Validation

Error Handling in FP: Either, Try, and Validation

Functional Programming Functional Programming 9 min read 1751 words Intermediate ExcellentWiki Editorial Team

Traditional error handling with exceptions breaks functional purity. A function that throws has two exit paths — success and failure — but only the success path is visible in the type signature. This hidden control flow violates referential transparency and makes composition unpredictable. Functional programming solves this by encoding failure in the return type, making error handling explicit, composable, and type-safe.

As Scott Wlaschin explains in “Domain Modeling Made Functional” and his popular “Railway Oriented Programming” talk, treating errors as values rather than control flow mechanisms leads to systems that are more reliable, more maintainable, and easier to reason about. The key insight is that every function should make its failure modes explicit in its type signature, so callers know exactly what to expect.

The Problem with Exceptions

Exceptions have several fundamental drawbacks in functional programming:

Hidden control flow — any function can throw at any point, making it impossible to determine the execution path without examining every nested call. This unpredictability undermines local reasoning, the ability to understand a function by examining its body alone.

Referential opacityf(x) + f(x) is not guaranteed to equal 2 * f(x) if f throws on the second call. Referential transparency, the property that an expression can be replaced with its value without changing program behavior, is broken by exceptions.

Type unsafety — the type signature def divide(a: Int, b: Int): Int does not reveal that division by zero will crash the program. The possibility of failure is invisible to both the compiler and the developer.

Composition difficulty — exception-throwing functions cannot be composed with pure functions using standard combinators like map and flatMap. The developer must wrap every call in try-catch blocks, scattering error handling logic throughout the codebase.

Joe Armstrong, co-creator of Erlang, captured the issue succinctly: “Exceptions are like GOTO statements — they introduce non-local control flow that makes programs impossible to reason about.” Functional error handling addresses these problems by making every possible failure explicit in the type system.

The Either Monad

Either represents a value that can be one of two types — conventionally Right for success and Left for failure. The names come from the convention that “right” is correct, though some libraries now use Ok and Error for clarity.

type Either<L, R> = Left<L> | Right<R>;

interface Left<L>  { tag: 'left';  value: L; }
interface Right<R> { tag: 'right'; value: R; }

function map<L, R, U>(e: Either<L, R>, f: (x: R) => U): Either<L, U> {
  return e.tag === 'right' ? { tag: 'right', value: f(e.value) } : e;
---

function flatMap<L, R, U>(e: Either<L, R>, f: (x: R) => Either<L, U>): Either<L, U> {
  return e.tag === 'right' ? f(e.value) : e;
---

Practical Usage with Either

The power of Either becomes apparent when composing multiple fallible operations:

function parseInteger(s: string): Either<string, number> {
  const n = parseInt(s, 10);
  return isNaN(n)
    ? { tag: 'left', value: `Cannot parse '${s}' as integer` }
    : { tag: 'right', value: n };
---

function divide(a: number, b: number): Either<string, number> {
  return b === 0
    ? { tag: 'left', value: 'Division by zero' }
    : { tag: 'right', value: a / b };
---

const result = flatMap(parseInteger('10'), x =>
  flatMap(parseInteger('2'), y =>
    divide(x, y)
  )
);

const badResult = flatMap(parseInteger('abc'), x =>
  flatMap(parseInteger('2'), y =>
    divide(x, y)
  )
);

If any step in the chain fails, the remaining steps are skipped and the error propagates automatically. This is the same short-circuiting behavior as exceptions, but encoded in the type system — the compiler knows that the result could be either a value or an error, and forces you to handle both cases.

Either in Different Languages

Haskell uses Either from the standard Prelude. Scala provides Either in the standard library, and libraries like Cats add functional combinators. Rust uses the similar Result<T, E> type, which has been one of the language’s most successful features — the Rust compiler enforces that both variants are handled, eliminating entire categories of runtime errors. TypeScript and JavaScript achieve Either through discriminated unions or libraries like fp-ts and purify-ts.

Try: Handling Exceptions in Functional Style

Try is a specialized version of Either designed for capturing exceptions. It wraps code that may throw and converts exceptions into typed failures:

sealed trait Try[+T]
case class Success[T](value: T) extends Try[T]
case class Failure[T](exception: Throwable) extends Try[T]

def safeDivide(a: Int, b: Int): Try[Int] = Try(a / b)

val result = for {
  x <- safeDivide(10, 2)
  y <- safeDivide(x, 3)
--- yield y

val failed = for {
  x <- safeDivide(10, 0)
  y <- safeDivide(x, 3)
--- yield y

When to Use Try vs Either

Use Try when wrapping legacy or third-party code that throws exceptions. Use Either when you control the error types and want domain-specific, typed error information. Either is more flexible because you define the error type — Try always fixes the error as Throwable, which loses the specific error information that domain modeling requires.

Validation: Error Accumulation

Validation addresses a limitation of Either: short-circuiting on the first error. When validating a form with multiple fields, you want to see all validation errors, not stop at the first one. Validation uses an applicative interface to accumulate errors:

def validateEmail(email: String): Validation[String, String] =
  if (email.contains("@")) Validation.success(email)
  else Validation.failure("Invalid email")

def validateAge(age: Int): Validation[String, Int] =
  if (age >= 0 && age < 150) Validation.success(age)
  else Validation.failure("Invalid age")

val result = Validation.combine(
  validateEmail("bad-email"),
  validateAge(200)
)

The key insight is that Validation separates the “parallel” combination of independent validations from the “sequential” composition of dependent operations. Form validation, configuration parsing, and request body validation all benefit from error accumulation. The Haskell library validation and the Scala library cats provide Validated types that implement this pattern.

Real-World Error Handling Patterns

Pattern: Recover and Fallback

Provide default values when operations fail:

function fetchUser(id: number): Either<Error, User> { }

const user = fold(
  fetchUser(42),
  (error) => defaultUser,
  (user) => user
);

Pattern: Contextual Error Enrichment

Attach context to errors as they propagate up the call stack:

function fetchUserProfile(id: number): Either<AppError, Profile> {
  return flatMap(
    fetchUser(id).mapLeft(e => e.withContext(`fetching user ${id}`)),
    user => fetchProfile(user.profileId)
  );
---

Pattern: Railway-Oriented Programming

Railway-oriented programming is a visual metaphor for composing error-handling functions. Each function is a switch that can take a train down the success track or the failure track. When a function succeeds, the result is passed to the next function. When it fails, the error bypasses remaining functions:

type Result<'TSuccess, 'TError> =
    | Success of 'TSuccess
    | Failure of 'TError

let bind switchFunction twoTrackInput =
    match twoTrackInput with
    | Success s -> switchFunction s
    | Failure f -> Failure f

let (>>=) x f = bind f x

let validateName name =
    if name <> "" then Success name
    else Failure "Name is required"

let validateAge age =
    if age >= 0 && age < 150 then Success age
    else Failure "Invalid age"

let createUser name age =
    validateName name >>= (fun n ->
    validateAge age >>= (fun a ->
    Success { Name = n; Age = a }))

This pattern, popularized by Scott Wlaschin, makes error flow visible and predictable — every function clearly shows both its success and failure paths. Wlaschin’s “Railway Oriented Programming” talk at NDC London has been one of the most influential presentations on functional error handling.

Pattern: Retry with Backoff

Retry failed operations with exponential backoff:

function retryWithBackoff<E, A>(
  action: () => Either<E, A>,
  maxRetries: number = 3
): Either<E, A> {
  let delay = 100;
  let result = action();
  let attempts = 0;

  while (result.tag === 'left' && attempts < maxRetries) {
    sleep(delay);
    delay *= 2;
    result = action();
    attempts++;
  }

  return result;
---

For production systems, consider using effect types like ZIO or Cats Effect that provide built-in retry and backoff combinators with configurable policies, jitter, and circuit-breaking.

Pattern: Domain-Specific Error Types

Rather than using generic error types, define domain-specific error types that make invalid states unrepresentable:

type ValidationError =
  | { type: 'required'; field: string }
  | { type: 'invalid_format'; field: string; expected: string }
  | { type: 'out_of_range'; field: string; min: number; max: number };

type Validated<T> = Either<ValidationError[], T>;

This approach, central to Scott Wlaschin’s “Domain Modeling Made Functional,” ensures that error handling is precise and type-safe. Each error variant carries exactly the information needed to produce a meaningful error message or recovery action.

Composing Error Handling with Effect Systems

Effect systems like ZIO (Scala), Cats Effect (Scala), and Haskell’s IO monad provide comprehensive error management that goes beyond simple Either types. These systems track error types in the type system and provide powerful combinators for error recovery, retry, timeout, and resource management:

import zio._

val program: ZIO[Any, Throwable, User] = for {
  raw      <- fetchFromNetwork(url)
  parsed   <- parseJson(raw)
  validated <- validateUser(parsed)
--- yield validated

val withRetry = program.retry(Schedule.exponential(1.second))

Effect systems make error handling composable at scale, letting you build robust distributed systems where every failure path is tracked and managed by the type system.

FAQ

What is the difference between Either and Validation?

Either is monadic — it short-circuits on the first error, making it ideal for sequential operations where each step depends on the previous one. Validation is applicative — it continues processing all inputs and accumulates errors, making it ideal for parallel validation of independent fields.

Should I use exceptions or typed errors in a functional codebase?

Use typed errors (Either, Result) for domain logic where errors are recoverable and part of normal operation. Exceptions should be reserved for truly exceptional conditions — programmer errors, resource exhaustion, and unrecoverable system failures.

How does Rust’s Result type compare to Either?

Rust’s Result<T, E> is equivalent to Either<E, T> and follows the same principles. Rust enforces handling both variants at compile time through its match expression, while many typed functional languages let errors propagate through monadic composition.

What is railway-oriented programming?

Railway-oriented programming is a metaphor for composing functions that can either succeed or fail. Each function is like a railway switch that sends a train down the success track or the failure track. By composing these switches, you build a complete system where error flow is visible and predictable.

Conclusion

Functional error handling treats failures as values rather than control flow mechanisms. Either provides type-safe short-circuiting for sequential operations, Try bridges the gap with exception-throwing code, and Validation accumulates multiple errors for parallel validation. All three approaches share the same fundamental insight: making error paths explicit in type signatures leads to more reliable, composable, and maintainable code.

For related concepts, explore Pure Functions Guide and Functional Programming Basics.

Section: Functional Programming 1751 words 9 min read Intermediate 756 articles in section Report inaccuracy Back to top