How to avoid For-Comprehension Pitfalls in Scala

Julien Truffaut

8th December 2023

For-comprehensions are a powerful feature in Scala, providing an elegant syntax for working with collections and various Scala types. In this post, we'll explore two prevalent mistakes and discuss strategies to overcome them.

1. Not yielding

One frequent oversight when using for-comprehensions is forgetting the yield keyword. Without it, the for-comprehension becomes a for-loop, executing side effects without returning a meaningful value. Consider this example:

val doubled = for { number <- List(1,2,3) } number // doubled: Unit = ()

In this case, doubled is of type Unit, rendering it essentially meaningless. For-loops without yield are suitable for side-effecting operations, such as printing to the console:

val doubled = for { number <- List(1,2,3) } println(s"The value is $number") // The value is 1 // The value is 2 // The value is 3
  1. Mixing Types

Another common mistake arises when attempting to combine different type constructors within a for-comprehension. For instance, mixing a List and a Future result in a type mismatch:

def fetchUser(userId: Int): Future[User] = ... for { userId <- List(1111, 3456, 8888) user <- fetchUser(userId) } yield user // [error] type mismatch; // [error] found : Future[User] // [error] required: IterableOnce[?] // [error] user <- fetchUser(userId) // ^

For-comprehensions translate to map and flatMap operations, and possibly withFilter if an if is used. Rewriting the problematic example helps understand the issue:

List(1111, 3456, 8888).flatMap(userId => fetchUser(userId).map(user => user ) )

The flatMap on the first line expects a function of type Int => List[A] but fetchUser is a function of type Int => Future[User]. The types don't match, hence the compilation error. Solving this issue can be approached in two ways.

a. Align the types

Attempt to adapt each line inside the for-comprehension so that they all return the same type constructor. For example:

import scala.util.Try val inputs: List[String] = List("123", "03") def parseToInt(s: String): Try[Int] = Try(s.toInt) for { input <- inputs number <- parseToInt(input).toOption.toList } yield number // res: List[Int]

I used toOption.toList on the result of parseToInt to transform a Try into a List. Now, both lines return a List and the for-comprehension works as expected.

b. Use another method

When aligning types is not possible, alternative methods can be employed. For instance, using the sequence method in the Future API:

def fetchUser(userId: Int): Future[User] = ... for { userId <- List(1111, 3456, 8888) user <- fetchUser(userId) } yield user

Or, using Future.traverse to avoid the map:

Future.sequence( List(1111, 3456, 8888).map(fetchUser) ) // res: Future[List[User]]

It is also possible to avoid the map using Future.traverse:

Future.traverse(List(1111, 3456, 8888))(fetchUser) // res: Future[List[User]]

While these approaches provide solutions, seamlessly mixing multiple type constructors inside a for-comprehension remains a functional programming "Holy Grail". Proposals like Monad Transformers, Free Monad, All-in-one Monads (e.g. ZIO), and Capabilities (e.g. Caprese) all come with their own set of trade-offs. The pursuit of a seamless solution continues in the functional programming community. Mastering for-comprehensions involves understanding these intricacies and choosing the right approach for each scenario.

Subscribe to receive the latest Scala jobs in your inbox

Receive a weekly overview of Scala jobs by subscribing to our mailing list

© 2024, All rights reserved.