Scala Programming Guidesscala-functional-programmingfunctional-programming

Error Handling Without Exceptions | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Error Handling Without Exceptions - Scala Programming Guide

Why Exceptions Break Functional Programming

Exceptions are control flow mechanisms that fundamentally violate functional programming principles because they hide the entire contract of a function in plain sight. A function's type signature is supposed to tell you everything about its contract: "given this input type, I return this output type." But exceptions break this contract—a function declared to return Int might throw IOException, NullPointerException, or any other exception, and the type signature reveals nothing. This creates invisible failure paths that callers must discover through documentation or painful debugging. Exceptions are not part of the return type; they're hidden escape hatches that completely subvert the type system. For example, if you see def parseInteger(str: String): Int, nothing in that signature tells you it might throw NumberFormatException. You have to read the documentation or learn through painful trial and error.

Furthermore, exceptions violate the principle of referential transparency: calling the same function with the same input might return a successful result the first time and throw an exception the next, depending on external state (network availability, file system state, resource allocation, etc.). This means you cannot reason about the program algebraically or safely parallelize it. If a function can throw, you can't substitute it with its result value because you don't know what result you'll get. Finally, exceptions destroy composition: you cannot elegantly chain operations using map/flatMap when any operation might throw an exception, because the exception handling becomes scattered across the entire call stack rather than declarative and composable. Instead of a clean pipeline of transformations, you get try-catch blocks scattered everywhere, each catching different exceptions, making the flow of data through transformations murky and error-prone.

Here are the core problems, each with concrete consequences:

  1. Hidden side effects: A function can throw without the type signature declaring it
  2. Non-deterministic behavior: Same input can produce different outputs (exception or result)
  3. Breaks composition: You can't chain operations that might fail using standard functional combinators
  4. Stack unwinding: The exact point of failure can be hard to trace, especially with asynchronous code

Functional programming solves this by making errors explicit types, encoding them in return values. Let's see the problem in action and how to fix it:

// PROBLEMATIC: Exceptions hide failure paths
def parseInteger(str: String): Int = {
  str.toInt  // Can throw NumberFormatException, but the type signature doesn't say so!
}

// When you call this, the type signature lies:
val result: Int = parseInteger("42")  // Looks pure, but might throw
val result2: Int = parseInteger("abc")  // Throws — but compiler doesn't warn you!

Functional programming uses types to encode failure. This is the fundamental shift: instead of exceptions breaking your program's control flow, you encode failure as data:

// BETTER: The type signature tells the truth
def parseIntegerFunctional(str: String): Option[Int] = {
  scala.util.Try(str.toInt).toOption  // Returns None if parsing fails
}

// Now the type signature is honest
val result: Option[Int] = parseIntegerFunctional("42")   // Some(42)
val result2: Option[Int] = parseIntegerFunctional("abc")  // None

// Callers must handle both cases
result match {
  case Some(num) => println(s"Parsed: $num")
  case None      => println("Could not parse")
}

Option[A] — Some and None

Option represents a value that may or may not exist. Instead of returning null (which breaks type safety) or throwing an exception (which hides errors), you return Option which explicitly indicates that the value might not be there. This eliminates NullPointerException entirely—if a function returns Option[User], you know for certain that it might return None, and the compiler forces you to handle both cases. Option is sealed (only two subtypes: Some and None), so the compiler verifies you've handled all cases. Option is also a monad, which means you can use it with map, flatMap, filter and all the functional operations we discussed earlier. This makes chaining optional operations natural and composable.

Option represents a value that may or may not exist:

// Option has two cases: Some(value) and None
val maybeAge: Option[Int] = Some(25)
val noAge: Option[Int] = None

// Extracting values
maybeAge match {
  case Some(age) => println(s"Age: $age")
  case None      => println("Age unknown")
}

// Option is sealed, so the compiler ensures you handle both cases

Real-world example — fetching user data. This shows the practical difference: instead of returning null (which is unsafe) or throwing an exception (which breaks composition), you return Option which makes the possibility of absence explicit:

case class User(id: Int, name: String, email: String)

// Simulated database
val userDatabase = Map(
  1 -> User(1, "Alice", "alice@example.com"),
  2 -> User(2, "Bob", "bob@example.com")
)

// Returns Option because user might not exist
def findUserById(id: Int): Option[User] = {
  userDatabase.get(id)  // Map.get returns Option
}

// Safe to chain operations on Option
val user1 = findUserById(1)
val user2 = findUserById(99)  // Returns None

println(user1)  // Some(User(1, "Alice", "alice@example.com"))
println(user2)  // None

Chaining Options with map

One of the most powerful aspects of Option is that you can chain operations on it using map, flatMap, filter, and other functional operations. If any step returns None, the whole chain becomes None—no need for explicit None-checks at each step. This composability is one of the main advantages of functional error handling:

// map transforms the value inside Some, passes through None

case class UserProfile(user: User, bio: String)

def findProfileForUser(user: User): Option[UserProfile] = {
  if (user.id <= 2) {
    Some(UserProfile(user, "Active user"))
  } else {
    None  // Profile not found
  }
}

// Chain operations without explicit pattern matching
val userProfile = findUserById(1)
  .flatMap(findProfileForUser)  // flatMap because findProfile returns Option
  .map(_.bio)

println(userProfile)  // Some("Active user")

// If any step returns None, the whole chain is None
val missingProfile = findUserById(99)
  .flatMap(findProfileForUser)
  .map(_.bio)

println(missingProfile)  // None — cleaner than try/catch!

getOrElse, orElse, fold

When you need to extract a value from an Option, or provide a default when it's None, use these utility methods. They give you different ways to handle the absence case:

// getOrElse provides a default value
val age: Option[Int] = Some(25)
val unknownAge: Option[Int] = None

println(age.getOrElse(0))        // 25
println(unknownAge.getOrElse(0)) // 0

// orElse: chain alternatives
val preferredEmail = findUserById(99).map(_.email).orElse(Some("support@example.com"))
// If user not found, use support email

// fold: handle both cases
def displayUser(userId: Int): String = {
  findUserById(userId).fold(
    ifEmpty = "User not found",
    f = user => s"Welcome, ${user.name}!"
  )
}

println(displayUser(1))   // "Welcome, Alice!"
println(displayUser(99))  // "User not found"

Either[E, A] — Typed Errors

Either represents a value that is either Right (success) or Left (error). Unlike Option which just says "it might be missing," Either lets you attach error information to the Left side. The type parameter tells you what error information is available: Either[String, Int] means the error is a String and success is an Int. This typed approach gives you rich error information that you can work with programmatically. You can pattern-match on Either, extract error details, and handle different types of errors differently. Either is also a monad, so you can use it with all the functional combinators. The key advantage over exceptions is that error handling is explicit and composable—errors are values, not control flow disruptions:

Either represents a value that is either Right (success) or Left (error). The type parameter tells you what error information is available:

// Either[String, Int] — error is a String, success is Int
def parseIntWithError(str: String): Either[String, Int] = {
  scala.util.Try(str.toInt).toEither match {
    case scala.util.Success(num) => Right(num)
    case scala.util.Failure(_)   => Left(s"Could not parse '$str' as integer")
  }
}

println(parseIntWithError("42"))   // Right(42)
println(parseIntWithError("abc"))  // Left("Could not parse 'abc' as integer")

// Either can hold rich error information
case class ValidationError(field: String, message: String)

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

Chaining Either operations allows you to compose multiple validations or operations that might fail. If any step returns Left, the entire chain short-circuits to that error. This makes validation pipelines clean and declarative:

case class RegistrationData(email: String, age: Int)

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

def validateAge(age: Int): Either[String, Int] = {
  if (age >= 18) Right(age) else Left("Must be 18 or older")
}

def registerUser(email: String, age: Int): Either[String, RegistrationData] = {
  // Use for-comprehension to chain Either operations
  for {
    validEmail <- validateEmail(email)
    validAge   <- validateAge(age)
  } yield RegistrationData(validEmail, validAge)
}

println(registerUser("alice@example.com", 25))  // Right(RegistrationData(...))
println(registerUser("invalid", 25))            // Left("Invalid email")
println(registerUser("bob@example.com", 16))    // Left("Must be 18 or older")

Try[A] — Catching Exceptions Functionally

Try is like Either, but specialized for catching exceptions. Instead of manually writing try-catch blocks that scatter error handling across your code, you wrap exception-throwing code in Try and it captures the exception (if one occurs) or the success value (if the operation succeeds). Try gives you Success (containing the value) or Failure (containing the exception). You can then use functional combinators (map, flatMap, filter, fold) to work with the result, chaining Try-based operations without explicit exception handling. Try is particularly useful when integrating with legacy code that throws exceptions:

Try is like Either, but specialized for catching exceptions:

import scala.util.{Try, Success, Failure}

// Try wraps exception-throwing code
def safeDivide(a: Int, b: Int): Try[Int] = {
  Try(a / b)  // If b == 0, this captures the exception
}

println(safeDivide(10, 2))  // Success(5)
println(safeDivide(10, 0))  // Failure(java.lang.ArithmeticException: / by zero)

// Chain Try operations
val result = for {
  x <- Try(scala.io.Source.fromFile("/nonexistent.txt").mkString)
  // If file doesn't exist, captured in Failure
} yield x.length

result match {
  case Success(length) => println(s"File has $length chars")
  case Failure(ex)     => println(s"Error: ${ex.getMessage}")
}

Combining Error-Handling Types

When you have multiple error sources, you need to combine them intelligently. You can use for-comprehensions to chain operations where each might fail, composing errors together. The key insight is that all these error types are monads that can be composed with flatMap and map:

When you have multiple error sources, combine them:

// Scenario: Parse a configuration file with multiple values

case class Config(host: String, port: Int, timeout: Int)

def parseConfig(data: Map[String, String]): Either[String, Config] = {
  // Each parse might fail
  val hostResult = data.get("host").toRight("Missing 'host'")
  val portResult = data.get("port")
    .toRight("Missing 'port'")
    .flatMap(p => scala.util.Try(p.toInt).toEither.left.map(_ => "Port must be integer"))

  val timeoutResult = data.get("timeout")
    .toRight("Missing 'timeout'")
    .flatMap(t => scala.util.Try(t.toInt).toEither.left.map(_ => "Timeout must be integer"))

  // Combine them
  for {
    host    <- hostResult
    port    <- portResult
    timeout <- timeoutResult
  } yield Config(host, port, timeout)
}

val validConfig = parseConfig(Map("host" -> "localhost", "port" -> "8080", "timeout" -> "30"))
println(validConfig)  // Right(Config("localhost", 8080, 30))

val missingPort = parseConfig(Map("host" -> "localhost", "timeout" -> "30"))
println(missingPort)  // Left("Missing 'port'")

Validating Data with Custom Error Accumulation

When you want to collect ALL errors (not stop at the first one), you need a different approach. The standard Either and Option short-circuit at the first error, which is good for most cases but sometimes you want to show users all the problems at once. You can accumulate errors by building them manually or using custom validation types. This is useful for form validation where you want to show all field errors simultaneously rather than one at a time:

When you want to collect ALL errors (not stop at the first one), use custom validation:

// Example: a recipe cost calculator that validates ingredients

case class Ingredient(name: String, quantity: Double, unitPrice: Double)
case class ValidationError(field: String, message: String)

def validateIngredient(ingredient: Ingredient): Either[List[ValidationError], Ingredient] = {
  val errors = scala.collection.mutable.ListBuffer[ValidationError]()

  if (ingredient.name.isEmpty) {
    errors += ValidationError("name", "Name cannot be empty")
  }

  if (ingredient.quantity <= 0) {
    errors += ValidationError("quantity", "Quantity must be positive")
  }

  if (ingredient.unitPrice <= 0) {
    errors += ValidationError("unitPrice", "Price must be positive")
  }

  if (errors.isEmpty) Right(ingredient) else Left(errors.toList)
}

// Test with multiple errors
val badIngredient = Ingredient("", -5.0, -10.0)
println(validateIngredient(badIngredient))
// Left(List(
//   ValidationError("name", "Name cannot be empty"),
//   ValidationError("quantity", "Quantity must be positive"),
//   ValidationError("unitPrice", "Price must be positive")
// ))

// Can now display all errors to user at once

Practical Example: Parsing and Validating User Registration Data

case class RegistrationForm(
  username: String,
  email: String,
  age: Int,
  password: String
)

case class ValidationFailure(errors: List[String])

// Individual validators
def validateUsername(username: String): Either[String, String] = {
  if (username.isEmpty) {
    Left("Username cannot be empty")
  } else if (username.length < 3) {
    Left("Username must be at least 3 characters")
  } else {
    Right(username)
  }
}

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

def validateAge(age: Int): Either[String, Int] = {
  if (age < 13) {
    Left("Must be at least 13 years old")
  } else if (age > 120) {
    Left("Invalid age")
  } else {
    Right(age)
  }
}

def validatePassword(password: String): Either[String, String] = {
  if (password.length < 8) {
    Left("Password must be at least 8 characters")
  } else if (!password.exists(_.isUpper)) {
    Left("Password must contain at least one uppercase letter")
  } else if (!password.exists(_.isDigit)) {
    Left("Password must contain at least one digit")
  } else {
    Right(password)
  }
}

// Composite validation that chains all checks
def validateRegistration(form: RegistrationForm): Either[ValidationFailure, RegistrationForm] = {
  val result = for {
    username <- validateUsername(form.username)
    email    <- validateEmail(form.email)
    age      <- validateAge(form.age)
    password <- validatePassword(form.password)
  } yield RegistrationForm(username, email, age, password)

  // Convert Left to our custom ValidationFailure
  result.left.map(err => ValidationFailure(List(err)))
}

// Usage
val goodForm = RegistrationForm("alice123", "alice@example.com", 25, "SecurePass1")
val badForm = RegistrationForm("ab", "invalid-email", 10, "weak")

println(validateRegistration(goodForm))
// Right(RegistrationForm("alice123", "alice@example.com", 25, "SecurePass1"))

println(validateRegistration(badForm))
// Left(ValidationFailure(List("Username must be at least 3 characters")))
// Note: stops at first error with this approach

// For error accumulation, collect all violations:
def validateRegistrationAccumulating(
    form: RegistrationForm
): Either[ValidationFailure, RegistrationForm] = {
  val errors = scala.collection.mutable.ListBuffer[String]()

  if (form.username.isEmpty || form.username.length < 3) {
    errors += "Username must be at least 3 characters"
  }
  if (!form.email.contains("@")) {
    errors += "Invalid email format"
  }
  if (form.age < 13 || form.age > 120) {
    errors += "Invalid age"
  }
  if (form.password.length < 8) {
    errors += "Password too short"
  }

  if (errors.isEmpty) {
    Right(form)
  } else {
    Left(ValidationFailure(errors.toList))
  }
}

println(validateRegistrationAccumulating(badForm))
// Left(ValidationFailure(List(
//   "Username must be at least 3 characters",
//   "Invalid email format",
//   "Invalid age",
//   "Password too short"
// )))
// Now user sees ALL problems at once!

Summary

Functional error handling:

  • Encodes errors in the type system
  • Makes error paths explicit and composable
  • Prevents silent failures
  • Allows accumulating multiple errors
  • Makes testing and reasoning about code easier