Option, Either, and Try: Handling Errors Gracefully
Introduction
One of the biggest sources of bugs in programming is the dreaded NullPointerException
. Traditional imperative languages rely on null values and exceptions for error handling, leading to defensive programming and fragile code. Scala provides elegant, functional alternatives: Option
, Either
, and Try
.
These types allow you to model the absence of values and potential failures explicitly in your type system. This makes your code more predictable, safer, and easier to compose. You'll never have to worry about null pointer exceptions again!
Option: Safe Handling of Nullable Values
Understanding Option
Option[T]
represents a value that might or might not exist. It has two subtypes:
Some(value)
- contains a valueNone
- represents the absence of a value
// Creating Options
val someValue: Option[String] = Some("Hello")
val noValue: Option[String] = None
// Option.apply handles null automatically
val fromNullable: Option[String] = Option("Hello") // Some("Hello")
val fromNull: Option[String] = Option(null) // None
println(someValue) // Some(Hello)
println(noValue) // None
println(fromNullable) // Some(Hello)
println(fromNull) // None
// Safe operations with Option
def divide(a: Double, b: Double): Option[Double] = {
if (b != 0) Some(a / b) else None
}
def sqrt(x: Double): Option[Double] = {
if (x >= 0) Some(math.sqrt(x)) else None
}
println(divide(10, 2)) // Some(5.0)
println(divide(10, 0)) // None
println(sqrt(16)) // Some(4.0)
println(sqrt(-4)) // None
// Pattern matching with Option
def processOption(opt: Option[Int]): String = opt match {
case Some(value) => s"Got value: $value"
case None => "No value available"
}
println(processOption(Some(42))) // Got value: 42
println(processOption(None)) // No value available
// Safe method calls with Option
case class Person(name: String, age: Option[Int])
val people = List(
Person("Alice", Some(25)),
Person("Bob", None),
Person("Charlie", Some(30))
)
def describeAge(person: Person): String = person.age match {
case Some(age) => s"${person.name} is $age years old"
case None => s"${person.name}'s age is unknown"
}
people.foreach(p => println(describeAge(p)))
// Working with collections of Options
val maybeNumbers = List(Some(1), None, Some(3), Some(4), None)
val definedNumbers = maybeNumbers.flatten // Remove None values
val doubled = maybeNumbers.map(_.map(_ * 2)) // Transform only Some values
println(definedNumbers) // List(1, 3, 4)
println(doubled) // List(Some(2), None, Some(6), Some(8), None)
Option Methods and Operations
val someNumber = Some(42)
val noNumber: Option[Int] = None
// Basic Option methods
println(someNumber.isDefined) // true
println(someNumber.isEmpty) // false
println(noNumber.isDefined) // false
println(noNumber.isEmpty) // true
// Get value with default
println(someNumber.getOrElse(0)) // 42
println(noNumber.getOrElse(0)) // 0
// Transform values with map
println(someNumber.map(_ * 2)) // Some(84)
println(noNumber.map(_ * 2)) // None
// Filter values
println(someNumber.filter(_ > 40)) // Some(42)
println(someNumber.filter(_ > 50)) // None
println(noNumber.filter(_ > 40)) // None
// Conditional operations
def isEven(n: Int): Boolean = n % 2 == 0
println(someNumber.filter(isEven)) // None (42 is even, but we expect odd)
println(Some(41).filter(isEven)) // None
println(Some(40).filter(isEven)) // Some(40)
// FlatMap for chaining Options
def safeDivide(a: Double, b: Double): Option[Double] =
if (b != 0) Some(a / b) else None
def safeSquareRoot(x: Double): Option[Double] =
if (x >= 0) Some(math.sqrt(x)) else None
// Chain operations that might fail
def safeDivideAndSqrt(a: Double, b: Double): Option[Double] = {
safeDivide(a, b).flatMap(safeSquareRoot)
}
println(safeDivideAndSqrt(16, 4)) // Some(2.0) - sqrt(16/4) = sqrt(4) = 2
println(safeDivideAndSqrt(16, 0)) // None - division by zero
println(safeDivideAndSqrt(-16, 4)) // None - sqrt of negative
// For-comprehension with Options
def calculateResult(a: Double, b: Double, c: Double): Option[Double] = {
for {
divided <- safeDivide(a, b)
sqrt <- safeSquareRoot(divided)
final <- safeDivide(sqrt, c)
} yield final
}
println(calculateResult(16, 4, 2)) // Some(1.0) - sqrt(16/4)/2 = 2/2 = 1
println(calculateResult(16, 0, 2)) // None - division by zero
println(calculateResult(-16, 4, 2)) // None - negative square root
// Convert Option to other types
println(someNumber.toList) // List(42)
println(noNumber.toList) // List()
println(someNumber.toRight("error")) // Right(42)
println(noNumber.toLeft("default")) // Left(None)
// Practical example: parsing strings safely
def parseInt(s: String): Option[Int] = {
try {
Some(s.toInt)
} catch {
case _: NumberFormatException => None
}
}
def parseDouble(s: String): Option[Double] = {
try {
Some(s.toDouble)
} catch {
case _: NumberFormatException => None
}
}
val numberStrings = List("42", "3.14", "not a number", "100", "invalid")
val parsedInts = numberStrings.map(parseInt)
val parsedDoubles = numberStrings.map(parseDouble)
println("Parsed integers:")
parsedInts.zip(numberStrings).foreach { case (result, original) =>
println(s" '$original' -> $result")
}
println("Parsed doubles:")
parsedDoubles.zip(numberStrings).foreach { case (result, original) =>
println(s" '$original' -> $result")
}
// Working with nested Options
val nestedOption: Option[Option[String]] = Some(Some("nested value"))
val flattened = nestedOption.flatten // Some("nested value")
val noneInner: Option[Option[String]] = Some(None)
val flattenedNone = noneInner.flatten // None
println(flattened) // Some(nested value)
println(flattenedNone) // None
// Option in data structures
case class User(id: Int, name: String, email: Option[String], age: Option[Int])
val users = List(
User(1, "Alice", Some("alice@example.com"), Some(25)),
User(2, "Bob", None, Some(30)),
User(3, "Charlie", Some("charlie@test.com"), None),
User(4, "Diana", None, None)
)
// Find users with email addresses
val usersWithEmail = users.filter(_.email.isDefined)
println(s"Users with email: ${usersWithEmail.map(_.name)}")
// Calculate average age of users with known ages
val knownAges = users.flatMap(_.age)
val averageAge = if (knownAges.nonEmpty) Some(knownAges.sum.toDouble / knownAges.length) else None
println(f"Average age: ${averageAge.map(age => f"$age%.1f").getOrElse("unknown")}")
// Create contact info
def createContactInfo(user: User): String = {
val emailPart = user.email.map(email => s"Email: $email").getOrElse("No email")
val agePart = user.age.map(age => s"Age: $age").getOrElse("Age unknown")
s"${user.name} - $emailPart, $agePart"
}
users.foreach(user => println(createContactInfo(user)))
Either: Explicit Error Handling
Understanding Either
Either[A, B]
represents a value that can be one of two types:
Left(value)
- typically used for errorsRight(value)
- typically used for success values
// Creating Either values
val success: Either[String, Int] = Right(42)
val failure: Either[String, Int] = Left("Something went wrong")
println(success) // Right(42)
println(failure) // Left(Something went wrong)
// Functions returning Either
def safeDivideEither(a: Double, b: Double): Either[String, Double] = {
if (b != 0) Right(a / b)
else Left("Division by zero")
}
def validateAge(age: Int): Either[String, Int] = {
if (age < 0) Left("Age cannot be negative")
else if (age > 150) Left("Age too high")
else Right(age)
}
def validateEmail(email: String): Either[String, String] = {
if (email.contains("@")) Right(email.toLowerCase)
else Left("Email must contain @")
}
// Testing validation functions
val ageTests = List(-5, 25, 200, 45)
val emailTests = List("test@example.com", "invalid-email", "user@domain.org")
println("Age validation:")
ageTests.foreach { age =>
validateAge(age) match {
case Right(validAge) => println(s" $age -> Valid: $validAge")
case Left(error) => println(s" $age -> Error: $error")
}
}
println("Email validation:")
emailTests.foreach { email =>
validateEmail(email) match {
case Right(validEmail) => println(s" '$email' -> Valid: '$validEmail'")
case Left(error) => println(s" '$email' -> Error: $error")
}
}
// Pattern matching with Either
def processResult(result: Either[String, Int]): String = result match {
case Right(value) => s"Success: $value"
case Left(error) => s"Error: $error"
}
println(processResult(Right(100))) // Success: 100
println(processResult(Left("Failed"))) // Error: Failed
// Either is right-biased (focuses on Right values)
println(success.map(_ * 2)) // Right(84)
println(failure.map(_ * 2)) // Left(Something went wrong)
println(success.flatMap(x => Right(x + 10))) // Right(52)
println(failure.flatMap(x => Right(x + 10))) // Left(Something went wrong)
// For-comprehension with Either
def createUser(name: String, ageInput: Int, emailInput: String): Either[String, User] = {
for {
validAge <- validateAge(ageInput)
validEmail <- validateEmail(emailInput)
} yield User(0, name, Some(validEmail), Some(validAge))
}
val userInputs = List(
("Alice", 25, "alice@example.com"),
("Bob", -5, "bob@test.com"),
("Charlie", 30, "invalid-email"),
("Diana", 200, "diana@example.com")
)
userInputs.foreach { case (name, age, email) =>
createUser(name, age, email) match {
case Right(user) => println(s"✓ Created user: ${user.name}")
case Left(error) => println(s"✗ Failed to create user '$name': $error")
}
}
Advanced Either Operations
// Multiple error accumulation with custom types
sealed trait ValidationError
case class AgeError(message: String) extends ValidationError
case class EmailError(message: String) extends ValidationError
case class NameError(message: String) extends ValidationError
def validateName(name: String): Either[NameError, String] = {
if (name.trim.isEmpty) Left(NameError("Name cannot be empty"))
else if (name.length < 2) Left(NameError("Name too short"))
else Right(name.trim)
}
def validateAgeTyped(age: Int): Either[AgeError, Int] = {
if (age < 0) Left(AgeError("Age cannot be negative"))
else if (age > 150) Left(AgeError("Age too high"))
else Right(age)
}
def validateEmailTyped(email: String): Either[EmailError, String] = {
if (!email.contains("@")) Left(EmailError("Email must contain @"))
else if (!email.contains(".")) Left(EmailError("Email must contain domain"))
else Right(email.toLowerCase)
}
// Combining validations
def validateUser(name: String, age: Int, email: String): Either[List[ValidationError], User] = {
val nameResult = validateName(name)
val ageResult = validateAgeTyped(age)
val emailResult = validateEmailTyped(email)
(nameResult, ageResult, emailResult) match {
case (Right(n), Right(a), Right(e)) =>
Right(User(0, n, Some(e), Some(a)))
case _ =>
val errors = List(nameResult, ageResult, emailResult).collect {
case Left(error) => error
}
Left(errors)
}
}
val userValidations = List(
("Alice", 25, "alice@example.com"),
("", -5, "invalid"),
("Bob", 200, "bob@"),
("A", 30, "test@example.com")
)
userValidations.foreach { case (name, age, email) =>
validateUser(name, age, email) match {
case Right(user) =>
println(s"✓ Valid user: ${user.name}")
case Left(errors) =>
println(s"✗ Validation errors for '$name':")
errors.foreach(error => println(s" $error"))
}
}
// Either utilities
def sequence[A, B](eithers: List[Either[A, B]]): Either[A, List[B]] = {
eithers.foldRight(Right(List.empty[B]): Either[A, List[B]]) { (either, acc) =>
for {
list <- acc
value <- either
} yield value :: list
}
}
val calculations = List(
safeDivideEither(10, 2),
safeDivideEither(15, 3),
safeDivideEither(20, 4)
)
val calculationsWithError = List(
safeDivideEither(10, 2),
safeDivideEither(15, 0), // This will fail
safeDivideEither(20, 4)
)
println("All successful calculations:")
sequence(calculations) match {
case Right(results) => println(s" Results: $results")
case Left(error) => println(s" Error: $error")
}
println("Calculations with error:")
sequence(calculationsWithError) match {
case Right(results) => println(s" Results: $results")
case Left(error) => println(s" Error: $error")
}
// Converting between Option and Either
val someValue = Some(42)
val noneValue: Option[Int] = None
val eitherFromSome = someValue.toRight("Value missing")
val eitherFromNone = noneValue.toRight("Value missing")
println(eitherFromSome) // Right(42)
println(eitherFromNone) // Left(Value missing)
val optionFromRight = Right(42).toOption
val optionFromLeft = Left("error").toOption
println(optionFromRight) // Some(42)
println(optionFromLeft) // None
// Swapping Left and Right
val rightValue = Right(42)
val leftValue = Left("error")
println(rightValue.swap) // Left(42)
println(leftValue.swap) // Right(error)
Try: Exception Handling
Understanding Try
Try[T]
handles exceptions functionally:
Success(value)
- operation succeededFailure(exception)
- operation threw an exception
import scala.util.{Try, Success, Failure}
// Creating Try values
val successfulTry = Try(42 / 2) // Success(21)
val failingTry = Try(42 / 0) // Failure(java.lang.ArithmeticException)
println(successfulTry) // Success(21)
println(failingTry) // Failure(java.lang.ArithmeticException: / by zero)
// Functions returning Try
def parseIntTry(s: String): Try[Int] = Try(s.toInt)
def safeFileRead(filename: String): Try[String] = Try {
// Simulated file reading
if (filename.endsWith(".txt")) s"Contents of $filename"
else throw new RuntimeException(s"Invalid file type: $filename")
}
def calculateSquareRoot(x: Double): Try[Double] = Try {
if (x < 0) throw new IllegalArgumentException("Cannot calculate square root of negative number")
else math.sqrt(x)
}
// Testing Try operations
val parseTests = List("42", "not-a-number", "100", "3.14")
parseTests.foreach { str =>
parseIntTry(str) match {
case Success(value) => println(s"'$str' -> $value")
case Failure(exception) => println(s"'$str' -> Error: ${exception.getMessage}")
}
}
val fileTests = List("data.txt", "config.xml", "readme.txt")
fileTests.foreach { filename =>
safeFileRead(filename) match {
case Success(content) => println(s"$filename -> $content")
case Failure(exception) => println(s"$filename -> Error: ${exception.getMessage}")
}
}
// Try with map and flatMap
val number = Try(10)
val doubled = number.map(_ * 2)
val halved = doubled.flatMap(x => Try(x / 2))
println(doubled) // Success(20)
println(halved) // Success(10)
// Chaining Try operations
def divideAndSqrt(a: Double, b: Double): Try[Double] = {
for {
divided <- Try(a / b)
sqrt <- calculateSquareRoot(divided)
} yield sqrt
}
val tryTests = List(
(16.0, 4.0), // Should work: sqrt(16/4) = sqrt(4) = 2
(16.0, 0.0), // Should fail: division by zero
(-16.0, 4.0) // Should fail: negative square root
)
tryTests.foreach { case (a, b) =>
divideAndSqrt(a, b) match {
case Success(result) => println(f"sqrt($a/$b) = $result%.2f")
case Failure(exception) => println(s"sqrt($a/$b) failed: ${exception.getMessage}")
}
}
// Try utilities
println(successfulTry.isSuccess) // true
println(successfulTry.isFailure) // false
println(failingTry.isSuccess) // false
println(failingTry.isFailure) // true
// Get value with default
println(successfulTry.getOrElse(0)) // 21
println(failingTry.getOrElse(0)) // 0
// Recover from failures
val recovered = failingTry.recover {
case _: ArithmeticException => -1
case _: Exception => -2
}
println(recovered) // Success(-1)
// RecoverWith for chaining
val recoveredWith = failingTry.recoverWith {
case _: ArithmeticException => Try(42)
case _ => Try(0)
}
println(recoveredWith) // Success(42)
// Filter with Try
val filtered = successfulTry.filter(_ > 20)
val filteredFailed = successfulTry.filter(_ > 30)
println(filtered) // Success(21)
println(filteredFailed) // Failure(java.util.NoSuchElementException)
// Transform both Success and Failure
val transformed = failingTry.transform(
success = x => x * 2,
failure = _ => new RuntimeException("Custom error message")
)
println(transformed) // Failure(java.lang.RuntimeException: Custom error message)
Practical Try Examples
import scala.util.{Try, Success, Failure}
import java.time.LocalDate
import java.time.format.DateTimeFormatter
// Configuration parsing with Try
case class Config(host: String, port: Int, timeout: Int, retries: Int)
def parseConfig(configMap: Map[String, String]): Try[Config] = {
for {
host <- Try(configMap("host"))
port <- Try(configMap("port").toInt)
timeout <- Try(configMap("timeout").toInt)
retries <- Try(configMap("retries").toInt)
} yield Config(host, port, timeout, retries)
}
val validConfig = Map(
"host" -> "localhost",
"port" -> "8080",
"timeout" -> "5000",
"retries" -> "3"
)
val invalidConfig = Map(
"host" -> "localhost",
"port" -> "not-a-number",
"timeout" -> "5000"
// missing retries
)
parseConfig(validConfig) match {
case Success(config) => println(s"Valid config: $config")
case Failure(exception) => println(s"Config error: ${exception.getMessage}")
}
parseConfig(invalidConfig) match {
case Success(config) => println(s"Valid config: $config")
case Failure(exception) => println(s"Config error: ${exception.getMessage}")
}
// Date parsing with Try
def parseDate(dateStr: String, format: String): Try[LocalDate] = Try {
LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(format))
}
val dateStrings = List(
("2024-01-15", "yyyy-MM-dd"),
("15/01/2024", "dd/MM/yyyy"),
("Jan 15, 2024", "MMM dd, yyyy"),
("invalid-date", "yyyy-MM-dd")
)
dateStrings.foreach { case (dateStr, format) =>
parseDate(dateStr, format) match {
case Success(date) => println(s"'$dateStr' -> $date")
case Failure(exception) => println(s"'$dateStr' -> Parse error")
}
}
// Combining multiple Try operations
def processUserData(name: String, ageStr: String, emailStr: String): Try[User] = {
for {
age <- Try(ageStr.toInt) if age > 0 && age < 150
email <- Try {
if (emailStr.contains("@")) emailStr.toLowerCase
else throw new IllegalArgumentException("Invalid email")
}
} yield User(0, name, Some(email), Some(age))
}
val userData = List(
("Alice", "25", "alice@example.com"),
("Bob", "not-a-number", "bob@test.com"),
("Charlie", "30", "invalid-email"),
("Diana", "-5", "diana@example.com")
)
userData.foreach { case (name, age, email) =>
processUserData(name, age, email) match {
case Success(user) => println(s"✓ Created user: ${user.name}")
case Failure(exception) => println(s"✗ Failed to create '$name': ${exception.getMessage}")
}
}
// File processing simulation
def processFile(filename: String): Try[String] = {
for {
content <- Try {
// Simulate file reading
if (filename.endsWith(".txt")) s"Content of $filename"
else throw new RuntimeException(s"Unsupported file type: $filename")
}
processed <- Try {
if (content.length > 5) content.toUpperCase
else throw new RuntimeException("Content too short")
}
} yield processed
}
val files = List("data.txt", "config.xml", "empty.txt", "readme.txt")
files.foreach { filename =>
processFile(filename) match {
case Success(result) => println(s"$filename -> $result")
case Failure(exception) => println(s"$filename -> Error: ${exception.getMessage}")
}
}
// Converting Try to Option and Either
val trySuccess = Try(42)
val tryFailure = Try(42 / 0)
println(trySuccess.toOption) // Some(42)
println(tryFailure.toOption) // None
println(trySuccess.toEither) // Right(42)
println(tryFailure.toEither) // Left(java.lang.ArithmeticException: / by zero)
Combining Option, Either, and Try
Working Together
import scala.util.{Try, Success, Failure}
// Combining all three for robust error handling
case class ValidationResult[T](value: T, warnings: List[String])
def validateAndParse(input: String): Either[String, Option[ValidationResult[Int]]] = {
if (input.trim.isEmpty) {
Right(None) // Empty input is valid but results in None
} else {
Try(input.toInt) match {
case Success(number) =>
val warnings = collection.mutable.ListBuffer[String]()
if (number < 0) warnings += "Negative number detected"
if (number > 1000) warnings += "Large number detected"
if (number % 100 == 0) warnings += "Round number detected"
Right(Some(ValidationResult(number, warnings.toList)))
case Failure(_) =>
Left(s"Cannot parse '$input' as integer")
}
}
}
val inputs = List("42", "500", "-10", "1500", "", "not-a-number", "100")
inputs.foreach { input =>
validateAndParse(input) match {
case Right(Some(ValidationResult(value, warnings))) =>
println(s"'$input' -> $value")
warnings.foreach(w => println(s" Warning: $w"))
case Right(None) =>
println(s"'$input' -> Empty input (valid)")
case Left(error) =>
println(s"'$input' -> Error: $error")
}
}
// Chain operations across different types
def safeCalculation(aStr: String, bStr: String): Either[String, Double] = {
for {
a <- Try(aStr.toDouble).toEither.left.map(_ => s"Invalid number: $aStr")
b <- Try(bStr.toDouble).toEither.left.map(_ => s"Invalid number: $bStr")
result <- if (b != 0) Right(a / b) else Left("Division by zero")
} yield result
}
val calculations = List(
("10.5", "2.0"),
("20", "not-a-number"),
("15.0", "0"),
("8", "4")
)
calculations.foreach { case (a, b) =>
safeCalculation(a, b) match {
case Right(result) => println(f"$a / $b = $result%.2f")
case Left(error) => println(s"$a / $b -> Error: $error")
}
}
// Practical example: User registration system
case class UserProfile(id: Int, name: String, email: String, age: Int)
def registerUser(
nameInput: Option[String],
emailInput: Option[String],
ageInput: Option[String]
): Either[List[String], UserProfile] = {
val errors = collection.mutable.ListBuffer[String]()
val name = nameInput.filter(_.trim.nonEmpty) match {
case Some(n) if n.length >= 2 => n.trim
case Some(_) =>
errors += "Name must be at least 2 characters"
""
case None =>
errors += "Name is required"
""
}
val email = emailInput.filter(_.trim.nonEmpty) match {
case Some(e) if e.contains("@") && e.contains(".") => e.toLowerCase
case Some(_) =>
errors += "Invalid email format"
""
case None =>
errors += "Email is required"
""
}
val age = ageInput.flatMap(s => Try(s.toInt).toOption) match {
case Some(a) if a >= 13 && a <= 120 => a
case Some(a) =>
errors += s"Age must be between 13 and 120 (got $a)"
0
case None =>
errors += "Valid age is required"
0
}
if (errors.isEmpty) {
Right(UserProfile(scala.util.Random.nextInt(10000), name, email, age))
} else {
Left(errors.toList)
}
}
val registrations = List(
(Some("Alice"), Some("alice@example.com"), Some("25")),
(Some("B"), Some("bob@test.com"), Some("30")),
(None, Some("charlie@example.com"), Some("35")),
(Some("Diana"), Some("invalid-email"), Some("40")),
(Some("Eve"), Some("eve@example.com"), Some("not-a-number")),
(Some("Frank"), Some("frank@example.com"), Some("200"))
)
registrations.foreach { case (name, email, age) =>
registerUser(name, email, age) match {
case Right(user) =>
println(s"✓ Registered: ${user.name} (${user.email})")
case Left(errors) =>
println(s"✗ Registration failed:")
errors.foreach(error => println(s" $error"))
}
println()
}
Summary
In this lesson, you've mastered functional error handling in Scala:
✅ Option: Safe handling of nullable values without null pointer exceptions
✅ Either: Explicit error handling with detailed error information
✅ Try: Exception handling in a functional way
✅ Composition: Combining these types for robust error handling
✅ Real-world Examples: Validation, parsing, and user registration systems
✅ Best Practices: When to use each type and how to combine them effectively
These types eliminate entire classes of bugs and make your code more predictable and maintainable.
What's Next
In the next lesson, we'll explore immutable collections in depth, learning about Lists, Vectors, Sets, Maps, and their performance characteristics. You'll discover how to choose the right collection for your use case and work with them efficiently.
Comments
Be the first to comment on this lesson!