CTCaliberTek
Course: Scala 3 Developer Path: From First Program to a Tested Project · Lesson 7 of 8Log in to track your progress and earn a certificate when you finish the course.
Programming

Scala Error Handling with Option, Either, and Try

Published November 9, 2026·Updated November 9, 2026·10 min read

Edited by , Senior Data Analyst. How we make our content

Like SaveLog in to like, save, and track progress.

Introduction

In many languages, a missing value is null and a failure is an exception, and neither shows up in a function's signature, so callers forget to handle them. Scala makes these cases part of the return type. A function that might not return a value returns an Option, one that can fail with a reason returns an Either, and code that might throw can be wrapped in a Try. The compiler then reminds callers to deal with the possibility.

Prerequisites

  • Pattern matching from the previous lesson
  • Collections and map, flatMap from earlier lessons

Step-by-Step Guide

Step 1: Represent a missing value with Option

An Option[A] is either Some containing a value, or None. A function that looks something up returns an Option so the possibility of nothing is visible in the type. The map method transforms the value if present and does nothing for None, so you can chain steps without checking for null at every stage. The getOrElse method supplies a default, and fold lets you handle both cases in a single expression. Prefer these over calling get, which throws if the Option is empty.

val users = Map(1 -> "Asha", 2 -> "Ben")

def findUser(id: Int): Option[String] = users.get(id)

println(findUser(1))                         // Some(Asha)
println(findUser(9))                         // None
println(findUser(1).map(_.toUpperCase))      // Some(ASHA)
println(findUser(9).getOrElse("guest"))      // guest
println(findUser(2).fold("none")(n => s"hi $n"))   // hi Ben

Step 2: Parse input safely

Methods such as toIntOption, toDoubleOption, and headOption return an Option instead of throwing when the input is bad. This is the right way to handle text from a user or a file, where bad values are normal, not exceptional. Combining it with flatMap lets you chain steps that may each fail, stopping at the first None. Collections have a flatMap that treats Option as a collection of zero or one element, which is a tidy way to drop the failures from a list.

"42".toIntOption                    // Some(42)
"forty-two".toIntOption            // None

val inputs = List("10", "x", "30", "")
inputs.flatMap(_.toIntOption)      // List(10, 30)
List.empty[Int].headOption         // None

Step 3: Return a reason for failure with Either

When the caller needs to know why something failed, use Either[E, A], where by convention Left carries the error and Right carries the success value. Either is right-biased, so map and flatMap work on the Right value and pass a Left through unchanged. This makes validation code compose well. Using a sealed trait or enum as the error type lets callers match on the specific reasons rather than parsing messages.

enum AgeError:
  case NotANumber(text: String)
  case OutOfRange(value: Int)

def parseAge(text: String): Either[AgeError, Int] =
  text.toIntOption match
    case None => Left(AgeError.NotANumber(text))
    case Some(n) if n < 0 || n > 120 => Left(AgeError.OutOfRange(n))
    case Some(n) => Right(n)

println(parseAge("34"))     // Right(34)
println(parseAge("abc"))    // Left(NotANumber(abc))
println(parseAge("200"))    // Left(OutOfRange(200))

Step 4: Wrap code that throws with Try

Many Java libraries signal problems by throwing exceptions. The Try type captures an exception thrown by a block of code and turns it into a value: Success holding the result, or Failure holding the exception. You can then use map, recover, and pattern matching instead of try and catch blocks scattered through the code. Use Try at the boundary where you call code that throws, and convert to Either or Option as you move into your own logic. Catch only what you can handle, since Try does not capture fatal errors such as running out of memory.

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

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

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

safeDivide(10, 0) match
  case Success(v) => println(s"result $v")
  case Failure(e) => println(s"failed: ${e.getMessage}")

Step 5: Chain steps with a for comprehension

A for comprehension works with Option, Either, and Try, because they all define map and flatMap. Each line extracts the value from a step, and if any step produces None or Left or Failure, the whole expression stops there and returns it, with no nested checks. The code reads like a straight-line sequence of steps while still handling every failure path. This is the main reason for using these types instead of exceptions: errors flow through the data, and the happy path stays easy to read.

def parsePositive(text: String): Either[String, Int] =
  text.toIntOption.toRight(s"'$text' is not a number")
    .filterOrElse(_ > 0, s"'$text' is not positive")

def total(a: String, b: String): Either[String, Int] =
  for
    x <- parsePositive(a)
    y <- parsePositive(b)
  yield x + y

println(total("5", "7"))     // Right(12)
println(total("5", "-1"))    // Left('-1' is not positive)
println(total("zz", "7"))    // Left('zz' is not a number)

Step 6: Interoperate with Java and decide when to throw

Java libraries may return null, and you can safely convert such a value with Option.apply, so that a null becomes None. Scala does not forbid exceptions, and it is reasonable to throw for programmer errors and truly unexpected situations, such as failing a require check on arguments. The rule of thumb is to use Option, Either, and Try for failures that callers should anticipate and handle, and exceptions for conditions that indicate a bug or an unrecoverable problem.

val fromJava: String = System.getenv("MAYBE_UNSET")   // may be null
val safe = Option(fromJava)                          // None if null
println(safe.getOrElse("not set"))

def withdraw(balance: Double, amount: Double): Double =
  require(amount > 0, "amount must be positive")     // a bug if violated
  balance - amount

FAQs

When should I use Option, Either, or Try?
Use Option when absence is the only thing that can go wrong. Use Either when the caller needs to know why it failed, and especially for validation. Use Try to wrap calls that throw exceptions, mainly Java libraries and I/O, and convert the result into your own types.
Why avoid calling .get on an Option?
Calling get on None throws an exception, which brings back exactly the kind of runtime error that Option is meant to prevent. Use getOrElse, fold, map, or pattern matching so every case is handled.
Is it wrong to use exceptions in Scala?
No. They are appropriate for unexpected errors and programmer mistakes, and Java libraries throw them constantly. The point is to keep expected failures in the type system, where the compiler helps callers handle them.

Summary

You have seen how Option, Either, and Try put missing values and failures into types, how to transform and combine them with map, flatMap, and for comprehensions, and how to convert from null and exceptions at the boundary with Java. Code written this way is easier to read and test because the failure paths are explicit. The final lesson turns everything into a real project, built and tested with sbt.

Related tutorials

Comments

Log in or create a free account to join the discussion.

  • No comments yet. Be the first to comment.