DEV Community

Alessandro Maclaine
Alessandro Maclaine

Posted on

Either Algebraic Data Type

The Either type is a fundamental data structure commonly used in functional programming for error handling and decision-making. It represents a value that can either be a success (Right) or a failure (Left). This is particularly useful for handling operations that might fail, allowing you to easily represent both outcomes.

This guide provides an overview of the Either type, the key functions associated with it, and how to use it in various scenarios.

Motivation and Role of Either in Functional Programming

In functional programming, error handling can be challenging because functions are typically pure, meaning they do not have side effects like throwing exceptions. The Either type provides a way to handle errors without breaking purity, allowing functions to return either a successful value (Right) or an error (Left).

Key Benefits of Either

  • Explicit Error Handling: Unlike throwing exceptions, Either makes error handling explicit. This forces developers to handle both success and failure cases, reducing the likelihood of unhandled errors. This is in contrast to exception-based error handling in Object-Oriented Programming (OOP) languages, where exceptions can be thrown from anywhere, making it harder to track and handle errors consistently.
  • Composability: Functional programming emphasizes composing functions. Either fits well into this model, allowing multiple computations to be chained together in a safe manner using functions like flatMap. This is similar to how monads are used in Haskell to handle computations that may fail, providing a consistent and composable way to manage errors.
  • Improved Readability: By representing success and failure in a single type, Either makes the control flow of programs clearer and more readable, especially when compared to traditional error handling mechanisms like return codes or exceptions.

Comparison with Other Error Handling Methods

Return Codes (C)

In languages like C, error handling is often done using return codes. Functions return an integer value to indicate success or failure, and the caller must check this value to determine if an error occurred. This approach is error-prone, as it relies on the programmer to remember to check the return value every time. It also lacks type safety, making it easy to accidentally ignore errors or misinterpret return values.

Example - Return Codes in C
#include <stdio.h>

int divide(int numerator, int denominator, int* result) {
  if (denominator == 0) {
    return -1; // Error code for division by zero
  }
  *result = numerator / denominator;
  return 0; // Success code
}

int main() {
  int result;
  int status = divide(10, 2, &result);
  if (status == 0) {
    printf("Result: %d\n", result);
  } else {
    printf("Error: Division by zero\n");
  }
  return 0;
}
Enter fullscreen mode Exit fullscreen mode

In this example, the caller must remember to check the return status of the divide function. Forgetting to do so can lead to undefined behavior, making the code less reliable and harder to maintain.

Exceptions (OOP Languages)

In many Object-Oriented Programming (OOP) languages like Java or C#, exceptions are used to handle errors. Exceptions allow for separating normal control flow from error handling, but they come with their own set of problems. Exceptions can be thrown from deep within the call stack, making it difficult to understand where an error originated and how to handle it appropriately. This can lead to unexpected behavior if exceptions are not caught properly. Additionally, exceptions break the flow of pure functions, making reasoning about the code harder.

Example - Exceptions in Java
public class Division {
  public static void main(String[] args) {
    try {
      int result = divide(10, 0);
      System.out.println("Result: " + result);
    } catch (ArithmeticException e) {
      System.out.println("Error: " + e.getMessage());
    }
  }

  public static int divide(int numerator, int denominator) {
    if (denominator == 0) {
      throw new ArithmeticException("Division by zero");
    }
    return numerator / denominator;
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, an exception is thrown when attempting to divide by zero. The try-catch block is used to handle the exception, but if the exception is not properly caught, it can lead to program crashes or unpredictable behavior. This makes the control flow harder to follow and increases the risk of unhandled errors.

Either in Functional Programming

The Either type addresses the shortcomings of return codes and exceptions by making error handling explicit and composable. Unlike return codes, Either provides type safety, ensuring that errors cannot be ignored. Unlike exceptions, Either does not break the flow of the program and keeps all possible outcomes visible at the type level, making the code easier to reason about and maintain.

Example - Using Either in TypeScript
import { Either } from "effect"

const divide = (numerator: number, denominator: number): Either<number, string> => {
  return denominator === 0 ? Either.left("Division by zero error") : Either.right(numerator / denominator)
}

const result = divide(10, 2)
const errorResult = divide(10, 0)

console.log(result) // Either.right(5)
console.log(errorResult) // Either.left("Division by zero error")
Enter fullscreen mode Exit fullscreen mode

In this example, the divide function explicitly returns an Either, which can be a Right containing the result or a Left containing an error message. This ensures that the caller must handle both cases, making the error handling explicit and the flow of the program more predictable.

With Either, error handling becomes enforced by the type system itself. The compiler will ensure that both Right and Left cases are addressed wherever the Either value is used. This leads to logical consistency and correctness, as developers cannot inadvertently ignore error scenarios. By having both success and failure paths represented as part of the type, Either ensures that functions consuming the result must explicitly account for both outcomes, leading to safer, more reliable code.

Rust and Haskell

In Rust, the Result type serves a similar purpose to Either. It is used for error handling and explicitly represents either a success (Ok) or an error (Err). This approach forces the programmer to handle both cases, similar to Either, and avoids the pitfalls of unchecked exceptions.

Example - Using Result in Rust
fn divide(numerator: i32, denominator: i32) -> Result<i32, String> {
    if denominator == 0 {
        Err(String::from("Division by zero error"))
    } else {
        Ok(numerator / denominator)
    }
}

fn main() {
    match divide(10, 2) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }

    match divide(10, 0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
}
Enter fullscreen mode Exit fullscreen mode

In this Rust example, the divide function returns a Result type, ensuring that both success and error cases are handled explicitly. This makes the control flow predictable and avoids runtime crashes due to unhandled errors. By using the Result type, Rust enforces error handling at compile-time, ensuring that the developer has accounted for both the Ok and Err scenarios, leading to logically consistent and safer code.

In Haskell, the Either type is a standard way to handle errors in a functional way, allowing for composable error handling without breaking purity.

Example - Using Either in Haskell
divide :: Int -> Int -> Either String Int
divide _ 0 = Left "Division by zero error"
divide numerator denominator = Right (numerator `div` denominator)

main :: IO ()
main = do
  print (divide 10 2) -- Right 5
  print (divide 10 0) -- Left "Division by zero error"
Enter fullscreen mode Exit fullscreen mode

In Haskell, the divide function returns an Either, which can be either Left for errors or Right for successful results. This explicit error handling is central to Haskell's functional paradigm, ensuring that errors are always accounted for. Since Either is part of the type signature, the compiler helps enforce correct usage, ensuring that every code path properly addresses both success and error cases.

How Either Improves Code Flow

The use of Either in functional programming offers several improvements to code flow compared to traditional error handling methods:

  1. Explicit Error Handling: By returning an Either type, functions explicitly indicate that they can fail. This forces the caller to handle both success and failure cases, reducing the risk of unhandled errors and making the code more reliable.

  2. Composability: With Either, functions can be composed easily using methods like flatMap. This allows for building complex workflows that handle errors at each step without interrupting the flow of the program. For example, chaining multiple operations that might fail becomes straightforward, as each step returns an Either that can be propagated or transformed.

   const parseNumber = (input: string): Either<number, string> => {
     const parsed = Number(input)
     return isNaN(parsed) ? Either.left("Invalid number") : Either.right(parsed)
   }

   const divideParsedNumber = (numerator: string, denominator: string): Either<number, string> => {
     return parseNumber(numerator).flatMap(num =>
       parseNumber(denominator).flatMap(den =>
         divide(num, den)
       )
     )
   }

   console.log(divideParsedNumber("10", "2")) // Either.right(5)
   console.log(divideParsedNumber("10", "0")) // Either.left("Division by zero error")
   console.log(divideParsedNumber("ten", "2")) // Either.left("Invalid number")
Enter fullscreen mode Exit fullscreen mode
  1. Pure Functions: Unlike exceptions, which can be thrown from anywhere and break the purity of functions, Either allows functions to remain pure. Pure functions are easier to test, debug, and reason about because they have no side effects and always produce the same output for the same input.

  2. Type Safety: Either provides type safety by enforcing that all possible outcomes of a function are represented in the return type. This prevents errors from being ignored or missed, making the program more robust and reducing the likelihood of runtime errors. By using Algebraic Data Types (ADTs) like Either, the compiler enforces the handling of all potential outcomes, ensuring logical consistency and correctness. This is a significant improvement over traditional error handling methods, where errors could be easily ignored or forgotten.

  3. Better Readability: By keeping both success and failure cases in the type signature, Either makes it clear what each function can return. This improves code readability and helps developers understand the possible outcomes without diving into the implementation details.

  4. Compiler-Enforced Correctness: One of the biggest advantages of using Either is that the type system and the compiler work together to enforce error handling. The compiler will flag any use of an Either value where both success and failure branches are not properly handled, ensuring that developers cannot inadvertently forget to handle an error. This is in stark contrast to exceptions, where the absence of a try-catch can lead to runtime failures.

Top comments (0)