Introduction to functional programming in Scala
Table of Contents
Using Either for Exception Handling in Scala
Exception handling using throw is a common pattern in many languages, but in Scala, it's often discouraged. This is because exceptions are not part of a function's type signature, making it harder for the compiler to track failures and for developers to reason about them.
A more functional and type-safe approach in Scala is to use Either[A, B]. This data type represents a value of one of two possible types—either Left[A] or Right[B]. By convention, Left is used to represent an error, and Right represents a successful result.
One of the most common operations when working with Either is extracting or transforming the underlying value. Here's a simple example using pattern matching:
1val e: Either[String, Int] = Right(5)
2e match {
3 case Right(i) => assert(i == 5)
4 case Left(_) => fail("Expected a Right value")
5}Either.fold
If you want to provide a default value when the Either contains an error, you can use fold:
1val e: Either[String, Int] = Left("Some error")
2val defaultValue = 10
3
4assert(e.fold(_ => defaultValue, identity) == 10)In the example above, e contains a Left[String]. The fold method takes two functions: one for handling the Left case, and another for the Right. The error case returns a default value, while the success case uses the identity function to return the value unchanged.
You can also use fold to transform the error into a custom error type:
1case class MyError(msg: String)
2
3val e: Either[String, Int] = Left("Some error")
4
5assert(e.fold(error => Left(MyError(error)), identity) == Left(MyError("Some error")))Here, we map the Left case to a custom MyError class while leaving the Right case unchanged.
Either.map
Since Either in Scala is right-biased, you can use map directly to transform the Right value:
1val e: Either[String, Int] = Right(5)
2
3assert(e.map(_ + 10) == Right(15))This makes Either a very useful and expressive tool for functional error handling, allowing you to write code that's both type-safe and easy to reason about.
Either.leftMap
The leftMap method is a useful function that applies a transformation to the left side of an Either. leftMap operates only on the Left side of the Either. It allows you to map (transform) the value inside the Left without affecting the Right side.
1sealed trait Error
2case class UserNotFoundError(name: String) extends Error
3case class UserAuthenticationFailedError(name: String) extends Error
4
5case class User(name: String)
6
7class UserService {
8 def getUserByName(name: String): Either[Error, User] = {
9 if (name == "john") Right(User(name))
10 else Left(UserNotFoundError(name))
11 }
12}
13
14val userService = new UserService()
15
16// Example with a user found:
17val userResult = userService.getUserByName("john").leftMap(_ => UserAuthenticationFailedError("john"))
18println(userResult)
19// Output: Right(User(john))
20
21// Example with a user not found:
22val userResultError = userService.getUserByName("alice").leftMap(_ => UserAuthenticationFailedError("alice"))
23println(userResultError)
24// Output: Left(UserAuthenticationFailedError(alice))Option in Scala
Option is a container type in Scala that represents the presence or absence of a value. It's either Some(value) or None, and it's a safer alternative to using null.
Option.fold
In Scala, Option.fold is a convenient method that allows you to handle both the Some and None cases in a single expression. It takes two parameters: a default value (or computation) to use when the option is None, and a function to apply when the option is Some.
This makes fold a concise alternative to pattern matching or using getOrElse combined with map.
1val maybeNumber: Option[Int] = Some(10)
2val result = maybeNumber.fold("No number found")(num => s"Number is $num")
3println(result) // Output: Number is 10
4
5val noNumber: Option[Int] = None
6val result2 = noNumber.fold("No number found")(num => s"Number is $num")
7println(result2) // Output: No number foundIn this example, fold returns the provided default string when the Option is None, and applies the function to the value inside Some otherwise.
Option.toRight
The toRight method converts an Option[A] into an Either[B, A]. It takes a left value of type B and returns:
• Right(a) if the Option is Some(a)
• Left(b) if the Option is None
This is useful when you want to switch from an optional value to a more expressive error-handling structure using Either.
1//If the user is found
2val user: Option[User] = Some(User("john", "password"))
3val result: Either[UserNotFoundError.type, User] = user.toRight(UserNotFoundError)
4// result: Right(User("john", "password"))
5
6//If user is not found
7val user: Option[User] = None
8val result: Either[UserNotFoundError.type, User] = user.toRight(UserNotFoundError)
9// result: Left(UserNotFoundError)With toRight, you gain the ability to attach a meaningful error to failed operations, making the failure case more informative than just a None.
1// Define the errors and data types
2
3sealed trait OrderError
4case object OrderMissingFieldError extends OrderError
5case object ProductNotFoundError extends OrderError
6
7case class OrderRequest(productId: Option[Int], quantity: Int)
8case class Product(id: Int, name: String)
9case class Order(product: Product, quantity: Int)
10
11trait ProductRepository {
12 def findProductById(id: Int): Option[Product]
13}
14
15object ProductRepository extends ProductRepository {
16 private val products = List(Product(1, "Laptop"), Product(2, "Phone"))
17
18 def findProductById(id: Int): Option[Product] =
19 products.find(_.id == id)
20}
21
22def processOrder(orderRequest: OrderRequest)(implicit repo: ProductRepository): Either[OrderError, Order] = {
23 // Step 1: Check for missing productId and handle error
24 val productIdOption: Option[Int] = orderRequest.productId
25 val productIdEither: Either[OrderError, Int] = productIdOption.toRight(OrderMissingFieldError) // if it is none, this Either will be a left value
26
27 // Step 2: Handle missing productId error or proceed to find the product
28 productIdEither match {
29 case Right(productId) =>
30 // Step 3: Find the product by its ID
31 val productOption = repo.findProductById(productId)
32
33 // Step 4: If product is found, create the order, otherwise return an error
34 productOption match {
35 case Some(product) =>
36 Right(Order(product, orderRequest.quantity)) // Success case
37 case None =>
38 Left(ProductNotFoundError) // Error: Product not found
39 }
40
41 case Left(error) =>
42 Left(error) // Propagate the error if productId is missing
43 }
44}
45
46val orderRequestValid = OrderRequest(Some(1), 2) // Valid order
47val orderRequestInvalid = OrderRequest(None, 2) // Invalid order (missing productId)
48val orderRequestProductNotFound = OrderRequest(Some(999), 2) // Invalid order (product not found)
49
50val resultValid = processOrder(orderRequestValid)(ProductRepository)
51val resultInvalid = processOrder(orderRequestInvalid)(ProductRepository)
52val resultProductNotFound = processOrder(orderRequestProductNotFound)(ProductRepository)
53
54println(resultValid) // Right(Order(Laptop, 2))
55println(resultInvalid) //Left(OrderMissingFieldError)
56println(resultProductNotFound) //Left(ProductNotFoundError)Option.map
The map method applies a function to the value inside an Option if it is Some, and produces a new Option containing the transformed result. If theOption is None, the function is not applied and None is returned unchanged.
1val someValue: Option[Int] = Some(10)
2val mappedValue: Option[Int] = someValue.map(_ * 2) // Some(20)
3
4val noneValue: Option[Int] = None
5val mappedNone: Option[Int] = noneValue.map(_ * 2) // NoneOption.flatten
The flatten method removes one level of nesting from anOption. This is useful when you have a structure like Option[Option[A]] and want to simplify it into Option[A]. Flattening is also available on collections, which makes it a common way to collapse a List[Option[A]] into a List[A] by discarding empty values and unwrapping the present ones.
1val nestedOption: Option[Option[Int]] = Some(Some(5))
2val flattenedOption: Option[Int] = nestedOption.flatten // Some(5)
3
4val partiallyNestedOption: Option[Option[Int]] = Some(None)
5val flattenedPartially: Option[Int] = partiallyNestedOption.flatten // None
6
7val emptyNestedOption: Option[Option[Int]] = None
8val flattenedEmpty: Option[Int] = emptyNestedOption.flatten // None
9
10// Example with collections
11val listOfOptions: List[Option[Int]] = List(Some(1), None, Some(3))
12val flattenedList: List[Int] = listOfOptions.flatten // List(1, 3)Option.flatMap
The flatMap method combines the functionality of map and flatten. It applies a function to the value inside an Option (if present), where the function itself returns another Option. The result is then flattened into a single Option. This pattern is especially useful for chaining together computations that may each return None, avoiding nested Option structures.
1def safeDivide(numerator: Int, denominator: Int): Option[Int] = {
2 if (denominator != 0) Some(numerator / denominator) else None
3}
4
5val result1: Option[Int] = Some(10).flatMap(n => safeDivide(n, 2)) // Some(5)
6val result2: Option[Int] = Some(10).flatMap(n => safeDivide(n, 0)) // None
7val result3: Option[Int] = None.flatMap(n => safeDivide(n, 2)) // NoneOption pattern matching
Scala's Option type is a powerful abstraction for handling the presence or absence of a value. One of the most idiomatic ways to work with it is through pattern matching, which makes it clear whether a value is present (Some) or absent (None).
1def processOption(opt: Option[String]): String = {
2 opt match {
3 case Some(value) => s"Value is: $value"
4 case None => "No value present"
5 }
6}
7
8// Example usage
9val someValue = Some("Hello Scala")
10val noValue = None
11
12println(processOption(someValue)) // Output: Value is: Hello Scala
13println(processOption(noValue)) // Output: No value presentTry in Scala
In Scala, the Try[T] type is a powerful construct for handling computations that may throw exceptions. It is conceptually similar to Either[A, B] in that it represents one of two possible outcomes: a Success[T], which holds a successfully computed value, or a Failure[T], which holds theThrowable that caused the computation to fail. This provides a functional, type-safe alternative to traditional try-catch blocks.
1import scala.util.{Try, Success, Failure}
2
3def divide: Try[Int] = {
4 val dividend = Try(StdIn.readLine("Enter an Int that you'd like to divide:
5").toInt)
6 val divisor = Try(StdIn.readLine("Enter an Int that you'd like to divide by:
7").toInt)
8 val problem = dividend.flatMap(x => divisor.map(y => x / y))
9
10 problem match {
11 case Success(v) =>
12 println("Result of " + dividend.get + "/" + divisor.get + " is: " + v)
13 Success(v)
14 case Failure(e) =>
15 println("You must've divided by zero or entered something that's not an Int. Try again!")
16 println("Info from the exception: " + e.getMessage)
17 divide
18 }
19}An important feature of Try is its ability to chain operations while automatically propagating exceptions along the way. In the above example,flatMap and map are used to compose operations. If all operations succeed, the result is wrapped in Success. If any step fails, the exception is captured in a Failure, which short-circuits the chain.
This makes Try especially useful for writing concise, declarative code that handles failures gracefully. Additionally, combinators such as recover and recoverWith allow you to specify default behavior when failures occur.
The recover method
The recover method is used to provide a default value in case of failure. It accepts a PartialFunction[Throwable, U], which is applied to the exception inside a Failure. If the function matches the exception, a new Success is returned with the default value; otherwise, the original Failure is propagated.
1def dangerousOperation(input: String): Int = {
2 if (input.isEmpty) throw new IllegalArgumentException("Input cannot be empty")
3 input.toInt
4}
5
6val result1: Try[Int] = Try(dangerousOperation("123")) // Success(123)
7val result2: Try[Int] = Try(dangerousOperation("")) // Failure(IllegalArgumentException)
8
9val recoveredResult1: Try[Int] = result1.recover {
10 case _: IllegalArgumentException => 0
11}
12// recoveredResult1 is Success(123)
13
14val recoveredResult2: Try[Int] = result2.recover {
15 case _: IllegalArgumentException => 0
16}
17// recoveredResult2 is Success(0)The recoverWith method
The recoverWith method works similarly to recover, but instead of returning a default value, it returns an alternative Try. This is useful when the recovery itself may involve another computation that can also succeed or fail.
1def anotherDangerousOperation(input: String): Try[Int] = {
2 if (input.length < 2) Failure(new RuntimeException("Input too short"))
3 else Try(input.substring(0, 1).toInt)
4}
5
6val result3: Try[Int] = Try(dangerousOperation("")) // Failure(IllegalArgumentException)
7
8val recoveredWithResult: Try[Int] = result3.recoverWith {
9 case _: IllegalArgumentException => anotherDangerousOperation("5")
10}
11// recoveredWithResult is Success(5)Understanding for Comprehension in Scala
1for {
2 x <- someExpression
3 y <- anotherExpression
4} yield somethingScala's for comprehension is syntactic sugar. It gets translated by the compiler into a combination of higher-order methods:
• flatMap for each intermediate binding (e.g., x <- ...)
• map for the final yield
• withFilter for if guards (e.g., if condition within the comprehension)
1for {
2 x <- Option(2)
3 y <- Option(3)
4} yield x + yThis comprehension will result in Option(5). Here's how it's desugared by the compiler:
1Option(2).flatMap { x =>
2 Option(3).map { y =>
3 x + y
4 }
5}Futures in Scala
Some operations, like querying a database or making an HTTP request, can take a noticeable amount of time to complete. Running such operations on the main thread would block further execution, negatively impacting performance. Scala's Future is designed to represent the result of an asynchronous computation that may not yet be available.
When we create a new Future, Scala runs the provided code on a separate thread. Once that code finishes, the result—either a successful value or an exception—is captured by the Future. To use Futures, we must first define an implicit ExecutionContext, which determines where and how the Future executes (i.e., which thread pool it uses).
1val forkJoinPool: ExecutorService = new ForkJoinPool(4) // pool will use up to 4 worker threads
2implicit val forkJoinExecutionContext: ExecutionContext =
3 ExecutionContext.fromExecutorService(forkJoinPool)
4
5val singleThread: Executor = Executors.newSingleThreadExecutor()
6implicit val singleThreadExecutionContext: ExecutionContext =
7 ExecutionContext.fromExecutor(singleThread)ExecutionContext is Scala's abstraction for managing thread execution in asynchronous computations, such as those involving Futures. You can create one from a Java Executor or ExecutorService. Declaring it as implicit allows it to be picked up automatically by Futures. Scala also offers a global ExecutionContext, backed by a ForkJoinPool that uses the number of available processors as its parallelism level:
1implicit val globalExecutionContext: ExecutionContext = ExecutionContext.globalScheduling a Future
1def generateMagicNumber(): Int = {
2 Thread.sleep(3000L)
3 23
4}
5val generatedMagicNumberF: Future[Int] = Future {
6 generateMagicNumber()
7}By wrapping generateMagicNumber() inside a Future, we instruct the runtime to execute it asynchronously on another thread. However, if we already have a result available, it doesn't make sense to perform an asynchronous operation. In that case, we can use Future.successful to create a pre-completed Future:
1def multiply(multiplier: Int): Future[Int] =
2 if (multiplier == 0) {
3 Future.successful(0)
4 } else {
5 Future(multiplier * generateMagicNumber())
6 }Callbacks
Instead of blocking the main thread while waiting for a Future to complete, we can register a callback to be triggered once the Future is finished. The onComplete method allows us to handle both success and failure cases asynchronously:
1def printResult[A](result: Try[A]): Unit = result match {
2 case Failure(exception) => println("Failed with: " + exception.getMessage)
3 case Success(number) => println("Succeed with: " + number)
4}
5magicNumberF.onComplete(printResult)In this example, the printResult function will be executed once magicNumberF finishes, whether it completes successfully or with an error.
If we only want to handle the success case and ignore any failures, we can use the foreach method instead:
1def printSucceedResult[A](result: A): Unit = println("Succeed with: " + result)
2magicNumberF.foreach(printSucceedResult)Unlike onComplete, the foreach method only runs the callback if the Future completes successfully. This is similar to how foreach behaves on Try or Option values.
Example for future
1import scala.concurrent.{Future, ExecutionContext}
2import scala.util.{Success, Failure}
3
4// Use global execution context (default thread pool)
5implicit val ec: ExecutionContext = ExecutionContext.global
6
7val f1 = Future {
8 println(s"Running f1 on thread ${Thread.currentThread().getName}")
9 Thread.sleep(1000) // simulate delay
10 10
11}
12
13val f2 = Future {
14 println(s"Running f2 on thread ${Thread.currentThread().getName}")
15 Thread.sleep(500) // shorter delay
16 20
17}
18
19// Compose futures using flatMap and map
20val combined = f1.flatMap { x =>
21 f2.map { y =>
22 println(s"Combining results: ${x} + ${y}")
23 x + y
24 }
25}
26
27combined.onComplete {
28 case Success(result) => println(s"Result: ${result}")
29 case Failure(e) => println(s"Error: ${e}")
30}
31
32// Keep JVM alive to see results (only for demo purposes)
33Thread.sleep(2000)Using for comprehension
1val combinedFor = for {
2 x <- Future { Thread.sleep(1000); 10 }
3 y <- Future { Thread.sleep(500); 20 }
4} yield x + y
5
6combinedFor.onComplete {
7 case Success(value) => println(s"Sum with for-comprehension: $value")
8 case Failure(e) => println(s"Error: $e")
9}
10
11Thread.sleep(2000)Monoids
A Monoid is an algebraic structure that defines how elements of a type can be combined together using a specific operation. It consists of two key components:
(A, A) => A — often called append, which combines two elements of the same type into one.zero — a special value that acts as a neutral element for the operation.For a type to form a valid monoid, these two laws must hold:
1trait Monoid[A] {
2 def append(f1: A, f2: A): A
3 def zero: A
4}
5
6object IntMonoid extends Monoid[Int] {
7 override def append(f1: Int, f2: Int): Int = f1 + f2
8 override def zero: Int = 0
9}
10
11// Associative
12IntMonoid.append(1, IntMonoid.append(2, 3)) // 6
13IntMonoid.append(IntMonoid.append(1, 2), 3) // 6
14
15// Identity
16IntMonoid.append(IntMonoid.zero, 2) // 2In the example above, we define a Monoid[Int] where the operation is addition (+) and the identity element is 0. This satisfies both the associativity and identity laws.
foldLeft and foldRight, allowing you to combine all elements in a collection using the monoid's operation:1// Folding with a monoid
2val lst: List[Int] = List(1, 3, 4)
3val sum = lst.foldLeft(IntMonoid.zero)(IntMonoid.append) // 8
4val sum2 = lst.foldRight(IntMonoid.zero)(IntMonoid.append) // 8Here, foldLeft and foldRight start with the monoid's identity element (0) and successively combine each element using the monoid's append operation.
Functors
A Functor represents a computational context or container that supports applying a function to its contents without changing the container's structure. It provides a general way to transform values while preserving context — for example, mapping over a List, an Option, or any user-defined type likeBox.
The essential operation of a functor is map. The map function takes a value inside the container and applies a transformation function to it, producing a new container with the transformed values — while keeping the same shape or structure.
1sealed trait Box[A] {
2 def map[B](f: A => B): Box[B] = this match {
3 case Empty() => Empty[B]()
4 case Full(v) => Full(f(v))
5 }
6
7 def fold[B](empty: B)(f: A => B): B = this match {
8 case Empty() => empty
9 case Full(v) => f(v)
10 }
11}
12
13case class Empty[A]() extends Box[A]
14case class Full[A](value: A) extends Box[A]In this example, Box acts as a Functor. If it contains a value, the function is applied to that value. If it's empty, nothing happens — the structure is preserved. This behavior mirrors how built-in Scala types like Option and List behave when using map.
Functor as a Typeclass
We can generalize the concept of Functor using a typeclass. A typeclass defines a common interface that different types can implement — in this case, providing their own version of map.
1trait Functor[F[_]] {
2 def map[A, B](fa: F[A])(f: A => B): F[B]
3}
4
5object SeqF extends Functor[Seq] {
6 override def map[A, B](fa: Seq[A])(f: A => B): Seq[B] = fa.map(f)
7}
8
9val l = List(1,2,3)
10val el: List[Int] = Nil
11val sl = List("Foo", "Bar")
12
13SeqF.map(l)(_ * 2) // List(2, 4, 6)
14SeqF.map(el)(_ * 2) // List()
15SeqF.map(sl)(_.length) // List(3, 3)Here, Functor[F] is a typeclass that works for any type constructor F[_], such as List, Option, or custom data types like Box. By implementing the map method, we describe how to transform values inside that structure.
In the example, SeqF defines a Functor instance for Seq. It provides the mapping behavior by delegating to Scala's built-in map. This allows us to apply transformations uniformly to any sequence, regardless of its element type.
Functor Laws
F.map(fa)(x => x) == faF.map(fa)(f).map(g) == F.map(fa)(g compose f)These laws guarantee that Functors behave consistently and can be composed safely. Conceptually, Functors allow you to apply transformations to values inside a context(like a box or list) without having to unwrap or reconstruct that context manually.
Monads
Sometimes, when we apply a function to the elements inside a container, the function itself returns another container. For example, applying a function that might fail could return an Option, or applying a function that produces multiple results might return a List. If we repeatedly apply such functions, we can easily end up with nested containers — structures like Option[Option[A]] or List[List[A]], which are cumbersome to work with.
To solve this problem, monads introduce the concept of flatMap — an operation that both maps a function over a container and then “flattens” the nested result into a single container layer. In addition, every monad defines a unit, which wraps a plain value into the monadic context.
In essence, a monad allows us to build chains of computations where each step might introduce its own context — such as optionality, failure, or multiple results — without needing to manually unwrap and rewrap values. This is what makes monads so powerful in functional programming: they abstract away the “plumbing” of chaining context-aware computations.
1sealed trait Box[A] {
2 def map[B](f: A => B): Box[B] = this match {
3 case Empty() => Empty[B]()
4 case Full(v) => Full[B](f(v))
5 }
6
7 // Similar to Option.fold: return default if empty, else apply function
8 def fold[B](empty: B)(f: A => B): B = this match {
9 case Empty() => empty
10 case Full(v) => f(v)
11 }
12
13 def flatMap[B](f: A => Box[B]): Box[B] = this match {
14 case Empty() => Empty[B]()
15 case Full(v) => f(v)
16 }
17}
18
19case class Empty[A]() extends Box[A]
20case class Full[A](value: A) extends Box[A]
21
22// Example usage:
23val e: Box[Int] = Empty()
24val f: Box[Int] = Full(3)
25
26e.flatMap(x => Full(x * 2)) // Empty()
27f.flatMap(x => Full(x * 2)) // Full(6)In the example above, flatMap lets us apply a function that returns another Boxand automatically removes one layer of nesting. If the source is Empty, the result stays empty; otherwise, we apply the function and flatten the result.
1trait Monad[M[_]] {
2 def flatMap[A, B](fa: M[A])(f: A => M[B]): M[B]
3 def unit[A](a: => A): M[A]
4}
5
6object SeqM extends Monad[Seq] {
7 override def flatMap[A, B](seq: Seq[A])(f: A => Seq[B]): Seq[B] = seq.flatMap(f)
8 override def unit[A](a: => A): Seq[A] = Seq(a)
9}
10
11// Example usage:
12val l = List(1, 2, 3)
13val el: List[Int] = Nil
14val sl = List("Foo", "Bar")
15
16println(SeqM.flatMap(l)(i => 1 to i)) // List(1, 1, 2, 1, 2, 3)
17println(SeqM.flatMap(el)(i => 1 to i)) // List()
18println(SeqM.flatMap(sl)(_.toSeq)) // List(F, o, o, B, a, r)Here, we define a Monad trait and create a specific instance for Seq. Notice how flatMap inSeq allows us to expand each element into multiple results and then flatten them into a single list.
Monads are thus a unifying abstraction for composing computations that produce context — whether that's optionality (Option), multiple results (Seq), side effects (IO), or asynchronous operations (Future). They make it possible to write expressive, declarative code without getting tangled in implementation details.
Map vs Flatmap
1val perms = (chars: Seq[Char]) =>
2 chars flatMap(a => chars filter (b => b != a)
3 map (b => (a, b)))
4
5val perms2 = (chars: Seq[Char]) =>
6 chars map (a => chars filter (b => b != a)
7 map (b => (a, b)))
8
9println(perms('1' to '3')) // Vector((1,2), (1,3), (2,1), (2,3), (3,1), (3,2))
10println(perms2('1' to '3')) // Vector(Vector((1,2), (1,3)), Vector((2,1), (2,3)), Vector((3,1), (3,2)))