Scalable.FYI
ServicesBlogAbout UsCONTACTS

Why Use Scala / For-Comprehensions

Engineering Team · 4 min read

2026-04-28

Why Use Scala / For-Comprehensions

Introduction

For-comprehensions are a powerful tool we reach for whenever a sequence of actions has to run in order, and each one depends on the result of the previous one, which may have failed. Unlike the single-purpose async/await sugar most languages ship, a for-comprehension is not tied to one type. The compiler rewrites it into the flatMap/map calls you would have written by hand, for anything that defines those two methods with the right shape. That covers Option, Either, List, Map and Future, plus any type of your own that has flatMap/map on it.

Every mainstream language has invested heavily in ergonomic syntax for exactly one member of that set: Future/Promise, via async/await. None of them extend that same ergonomics to other types, the way Scala did.

Using, as inspiration, the code from our last post about Option data type, where we chained several Option-returning calls by hand through its flatMap/map methods, today we will show how a for-comprehension gives you one syntax that works on any monadic data type.

Note: we will assume that methods prefixed by 'fetch' are asynchronous, while the ones prefixed by 'find' return an Optional/Option .

Python: async/await for coroutines, nothing else

Python's await works only inside an async def, and only on awaitables. It is pleasant sugar that lets you write several asynchronous calls as if they ran one after another. Chain a sequence of operations returning an Optional, structurally the same problem, and none of that syntax applies: you are back to nested if checks.

Python
# async/await: nice, but only for coroutines.
async def fetch_city(user_id: str) -> Profile:
    user = await fetch_user(user_id)
    address = await fetch_address(user)
    return address.city

# The same "chain of things that might come back empty" shape,
# for Optional instead of Awaitable, gets no equivalent sugar:
def find_city(user_id: str) -> Optional[str]:
    user = find_user(user_id)
    if user is None:
        return None
    address = find_address(user)
    if address is None:
        return None
    return address.city

TypeScript: same story as Python

async/await is excellent for promises specifically, and TypeScript inherits it unmodified from JavaScript. There's no comprehension-style syntax for composing a sequence of T | undefined steps, that's manual optional chaining, one ?. at a time, as we saw last post.

Java: CompletableFuture chains, no for-comprehension at all

Java has no await keyword and no composition sugar in the language itself. Composing CompletableFutures means a chain of .thenCompose() calls. Optional chaining is structurally the same shape, but it uses a separate set of methods that share nothing with the future-composition story.

Both shapes, side by side:

Java
CompletableFuture<String> fetchCity(String userId) {
    return fetchUser(userId)
        .thenCompose(user -> fetchAddress(user)
            .thenApply(address -> address.city()));
}

// Structurally the same "chain of future steps", but Optional
// composition has a completely separate vocabulary from Future composition.
Optional<String> findCity(String userId) {
    return findUser(userId)
        .flatMap(user -> findAddress(user))
            .map(Address::city);
}

Scala: one syntax for any monadic type

A Scala for-comprehension is pure syntactic sugar: the compiler rewrites it into nested flatMap/map calls, and that is the whole mechanism. So it works on any type implementing those two methods with the right shape (forming a so-called Monad, which we won't discuss here), not just on one type that the language happens to support.

In the previous language examples, the snippets all started with the asynchronous code first; now we will take the inverse approach, showcasing first how to rewrite the Scala code of the previous post ...

Scala
def findUser(email: String): Option[User] = users.find(_.email == email)
def findAddress(user: User): Option[Address] = addresses.get(user.id)

def findCity(email: String): Option[String] = {
  findUser(email)
    .flatMap(findAddress)
    .map(_.city)
}

... into a idiomatic-Scala one:

Scala
def findCity(userId: String): Option[String] = for {
  user    <- findUser(userId)
  address <- findAddress(user.address)
} yield address.city

And now, the interesting part: because Future is a monad, with the exact same syntax we can compose asynchronous code as well!

Scala
def fetchCity(userId: String): Future[String] = for {
  user    <- fetchUser(userId)
  address <- fetchAddress(user.address)
} yield address.city

Similarly, since Either provides monadic flatMap/map, you can also write fail-fast validators such as this one (assuming each operation returns an Option, which we naively turn into an Either via the toRight method in this contrived example):

Scala
sealed trait ValidationError
object ValidationError {
  case object OutOfStock extends ValidationError
  case object PaymentDeclined extends ValidationError
  case object ShippingUnavailable extends ValidationError
}

def validateOrder(order: Order): Either[ValidationError, ValidatedOrder] = for {
  _        <- checkInventory(order).toRight(ValidationError.OutOfStock)
  payment  <- authorizePayment(order).toRight(ValidationError.PaymentDeclined)
  shipping <- computeShipping(order).toRight(ValidationError.ShippingUnavailable)
} yield ValidatedOrder(order, payment, shipping)

Another benefit you will appreciate, especially when the number of actions in a for comprehension grow (say, five or more), is that the code is more readable than a chain of op1().flatMap(op2)....flatMap(opN-1).map(opN) or, sometimes, a series of deeply-nested flatMap .

Did you know that collections such as List, Seq and so on do provide flatMap/map as well ? And yes, you can use for-comprehensions on them too:

Scala
// Still a for comprehension, different behavior: all permutations of size and color
val skus: List[String] = for {
  size  <- List("S", "M", "L")
  color <- List("red", "blue")
} yield s"$size-$color"
// List(S-red, S-blue, M-red, M-blue, L-red, L-blue)

Four different computational shapes (optional values, async work, fail-fast validation, combinatorial iteration) written with identical syntax. Learn how a for-comprehension works once and it applies to every monadic type you meet, including the ones you write yourself.

Why this reduces AI-introduced bugs

When an AI agent needs to compose Optional values in Java, it reaches for one API. When it needs to compose futures in the same codebase, it reaches for a structurally different one, and has to correctly judge which vocabulary applies each time. That's an extra decision point per call site, and extra decision points are exactly where AI-generated code tends to mix up the wrong method (calling .map() where .flatMap() was needed and silently nesting an Optional<Optional<T>>, for instance).

One for-comprehension syntax that behaves the same across Option, Either, List and Future means an agent applies the same model everywhere instead of relearning an API per type. Fewer vocabularies to keep straight, fewer places to reach for the wrong one.