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.
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
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.
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.
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.