Scala - Either/Left/Right with Examples

Either[A, B] is an algebraic data type that represents a value of one of two possible types. It has exactly two subtypes: Left and Right. By convention, Left represents failure or error cases while...

Key Insights

  • Either represents a value of one of two possible types (a disjoint union), conventionally used for error handling where Left contains error information and Right contains success values
  • Unlike Option which only signals absence, Either preserves error context, making it superior for validation chains and complex error scenarios where you need to communicate what went wrong
  • Either is right-biased in Scala 2.12+, meaning map, flatMap, and for-comprehensions operate on Right values by default, treating Left as a short-circuit failure case

Understanding Either Fundamentals

Either[A, B] is an algebraic data type that represents a value of one of two possible types. It has exactly two subtypes: Left and Right. By convention, Left represents failure or error cases while Right represents success.

sealed trait Either[+A, +B]
case class Left[+A](value: A) extends Either[A, Nothing]
case class Right[+B](value: B) extends Either[Nothing, B]

The key difference between Either and Option is that Either carries information about failures. While Option[T] can only tell you “something went wrong,” Either[Error, T] tells you “this specific error occurred.”

def divide(a: Int, b: Int): Either[String, Int] = {
  if (b == 0) Left("Division by zero")
  else Right(a / b)
}

val result1 = divide(10, 2)  // Right(5)
val result2 = divide(10, 0)  // Left("Division by zero")

Pattern Matching with Either

The most straightforward way to work with Either is pattern matching:

def parseAge(input: String): Either[String, Int] = {
  try {
    val age = input.toInt
    if (age < 0) Left("Age cannot be negative")
    else if (age > 150) Left("Age seems unrealistic")
    else Right(age)
  } catch {
    case _: NumberFormatException => Left(s"'$input' is not a valid number")
  }
}

val age = parseAge("25")
age match {
  case Right(value) => println(s"Valid age: $value")
  case Left(error) => println(s"Error: $error")
}

Right-Biased Operations

Since Scala 2.12, Either is right-biased, meaning operations like map and flatMap work on the Right value:

def validateUsername(name: String): Either[String, String] = {
  if (name.isEmpty) Left("Username cannot be empty")
  else if (name.length < 3) Left("Username too short")
  else Right(name)
}

def validateEmail(email: String): Either[String, String] = {
  if (email.contains("@")) Right(email)
  else Left("Invalid email format")
}

// map operates on Right values
val result = validateUsername("john").map(_.toUpperCase)
// result: Right("JOHN")

val failed = validateUsername("").map(_.toUpperCase)
// failed: Left("Username cannot be empty") - map not applied

Chaining Operations with flatMap

flatMap allows you to chain Either-returning operations, short-circuiting on the first Left:

case class User(username: String, email: String, age: Int)

def validateUser(username: String, email: String, ageStr: String): Either[String, User] = {
  for {
    validUsername <- validateUsername(username)
    validEmail <- validateEmail(email)
    validAge <- parseAge(ageStr)
  } yield User(validUsername, validEmail, validAge)
}

// Success case
validateUser("john_doe", "john@example.com", "30")
// Right(User(john_doe,john@example.com,30))

// Failure case - stops at first error
validateUser("jo", "john@example.com", "30")
// Left("Username too short")

Accumulating Errors with Validated

Either short-circuits on the first error. When you need to accumulate all errors, use cats.data.Validated:

import cats.data.ValidatedNec
import cats.implicits._

type ValidationResult[A] = ValidatedNec[String, A]

def validateUserAccumulating(
  username: String, 
  email: String, 
  ageStr: String
): ValidationResult[User] = {
  (
    validateUsername(username).toValidatedNec,
    validateEmail(email).toValidatedNec,
    parseAge(ageStr).toValidatedNec
  ).mapN(User)
}

validateUserAccumulating("", "invalid-email", "not-a-number")
// Invalid(NonEmptyChain(
//   "Username cannot be empty",
//   "Invalid email format",
//   "'not-a-number' is not a valid number"
// ))

Converting Between Either and Other Types

Either provides several conversion methods:

val right: Either[String, Int] = Right(42)
val left: Either[String, Int] = Left("error")

// Either to Option (loses error information)
right.toOption  // Some(42)
left.toOption   // None

// Either to Try
right.toTry  // Success(42)
left.toTry   // Failure(NoSuchElementException: Either.left.get on Right)

// Option to Either
val some: Option[Int] = Some(42)
val none: Option[Int] = None

some.toRight("No value present")  // Right(42)
none.toRight("No value present")  // Left("No value present")

// Try to Either
import scala.util.{Try, Success, Failure}

Success(42).toEither  // Right(42)
Failure(new Exception("error")).toEither  // Left(java.lang.Exception: error)

Practical Example: Configuration Parsing

Here’s a realistic example parsing configuration with detailed error reporting:

case class DatabaseConfig(host: String, port: Int, database: String)

object ConfigParser {
  def getEnv(key: String): Either[String, String] = {
    sys.env.get(key).toRight(s"Missing environment variable: $key")
  }
  
  def parsePort(portStr: String): Either[String, Int] = {
    try {
      val port = portStr.toInt
      if (port > 0 && port < 65536) Right(port)
      else Left(s"Port $port out of valid range (1-65535)")
    } catch {
      case _: NumberFormatException => 
        Left(s"Invalid port number: $portStr")
    }
  }
  
  def loadDatabaseConfig(): Either[String, DatabaseConfig] = {
    for {
      host <- getEnv("DB_HOST")
      portStr <- getEnv("DB_PORT")
      port <- parsePort(portStr)
      database <- getEnv("DB_NAME")
    } yield DatabaseConfig(host, port, database)
  }
}

// Usage
ConfigParser.loadDatabaseConfig() match {
  case Right(config) => 
    println(s"Connecting to ${config.host}:${config.port}/${config.database}")
  case Left(error) => 
    System.err.println(s"Configuration error: $error")
    sys.exit(1)
}

Advanced Pattern: Bimap and Fold

Transform both sides of an Either or collapse it into a single value:

val result: Either[String, Int] = divide(10, 2)

// Transform both Left and Right
val transformed = result.bimap(
  error => s"Error occurred: $error",
  value => value * 2
)

// Fold into a single value
val message = result.fold(
  error => s"Failed: $error",
  value => s"Result: $value"
)

// getOrElse provides default for Left
val value = divide(10, 0).getOrElse(0)  // 0

Error Handling with Custom Error Types

Using sealed traits for errors provides type-safe error handling:

sealed trait ValidationError
case class EmptyField(field: String) extends ValidationError
case class InvalidFormat(field: String, reason: String) extends ValidationError
case class OutOfRange(field: String, min: Int, max: Int) extends ValidationError

def validateAge(age: Int): Either[ValidationError, Int] = {
  if (age < 0) Left(OutOfRange("age", 0, 150))
  else if (age > 150) Left(OutOfRange("age", 0, 150))
  else Right(age)
}

validateAge(-5) match {
  case Right(age) => println(s"Valid: $age")
  case Left(OutOfRange(field, min, max)) => 
    println(s"$field must be between $min and $max")
  case Left(error) => println(s"Validation error: $error")
}

Either provides a robust foundation for error handling in Scala applications. Its right-biased nature makes it ergonomic for success-path programming while preserving complete error context. Use Either when you need to communicate failure reasons, and consider Validated when you need to accumulate multiple errors.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.