Scala
Future
Asynchronous Programming
Concurrency
Functional Programming

How can I flatten this FutureT structure?

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

Nested asynchronous types in Scala usually appear when code mixes Future with transformers such as EitherT or OptionT and then unwraps at the wrong level. Flattening means composing in the effect you already have instead of creating an extra layer that every caller now has to peel apart.

How Accidental Nesting Happens

The most common cause is using map when the callback already returns a Future. That creates Future[Future[A]], which is usually not what you wanted.

scala
1import scala.concurrent.{ExecutionContext, Future}
2
3def loadUser(id: String)(implicit ec: ExecutionContext): Future[String] =
4  Future.successful(s"user-$id")
5
6def loadPermissions(user: String)(implicit ec: ExecutionContext): Future[List[String]] =
7  Future.successful(List("read", "write"))
8
9def nested(id: String)(implicit ec: ExecutionContext): Future[Future[List[String]]] =
10  loadUser(id).map(user => loadPermissions(user))

The type is valid, but it makes the rest of the program awkward because every consumer now has to flatten the extra layer.

Use flatMap When the Callback Returns Future

If the next step is already asynchronous, switch from map to flatMap. That removes one level automatically.

scala
def flattened(id: String)(implicit ec: ExecutionContext): Future[List[String]] =
  loadUser(id).flatMap(user => loadPermissions(user))

This is the simplest mental rule for Future: use map when the callback returns a plain value, and flatMap when it returns another Future.

Use a For-Comprehension for Readability

When there are several asynchronous steps, a for-comprehension is usually easier to read than a chain of nested flatMap calls.

scala
1def flattenedFor(id: String)(implicit ec: ExecutionContext): Future[List[String]] =
2  for {
3    user <- loadUser(id)
4    permissions <- loadPermissions(user)
5  } yield permissions

This does not change the semantics. It just makes the control flow easier to follow, especially when validation or error-handling steps are mixed in.

Flatten Collections of Futures With sequence and traverse

Another common nesting shape is List[Future[A]]. In that case, use Future.sequence or Future.traverse instead of trying to combine the list manually.

scala
1import scala.concurrent.Future
2
3val jobs: List[Future[Int]] = List(
4  Future.successful(10),
5  Future.successful(20),
6  Future.successful(30)
7)
8
9val all: Future[List[Int]] = Future.sequence(jobs)

If you start with plain values and each value needs an async transformation, traverse is often cleaner:

scala
val ids = List("a", "b", "c")
val usersF: Future[List[String]] = Future.traverse(ids)(loadUser)

Keep Transformer Logic in Transformer Space

With EitherT or OptionT, the cleanest approach is to keep composing inside the transformer and unwrap only once at the boundary.

scala
1import cats.data.EitherT
2import scala.concurrent.{ExecutionContext, Future}
3
4def parseId(raw: String): Either[String, Int] =
5  raw.toIntOption.toRight("invalid id")
6
7def fetchName(id: Int)(implicit ec: ExecutionContext): Future[String] =
8  Future.successful(s"name-$id")
9
10def program(raw: String)(implicit ec: ExecutionContext): EitherT[Future, String, String] =
11  for {
12    id <- EitherT.fromEither[Future](parseId(raw))
13    name <- EitherT.right[String](fetchName(id))
14  } yield name
15
16val result: Future[Either[String, String]] = program("42").value

Notice that .value appears once at the outer boundary. That is usually the right place to unwrap.

Do Not Flatten by Blocking

A common anti-pattern is to “flatten” async code by calling Await.result early. That does not simplify the effect structure. It just blocks a thread and usually makes the design worse.

Keep composition asynchronous and add recovery explicitly:

scala
1import scala.util.control.NonFatal
2
3def safePermissions(id: String)(implicit ec: ExecutionContext): Future[List[String]] =
4  flattenedFor(id).recover {
5    case NonFatal(ex) =>
6      println(s"failed to load permissions: ${ex.getMessage}")
7      List.empty
8  }

This preserves the non-blocking model and makes failure behavior visible.

Common Pitfalls

The most common mistake is using map out of habit when the callback already returns Future. Another is unwrapping transformers repeatedly with .value in the middle of business logic instead of at the boundary. Developers also reach for Await.result to escape nested async types, which usually creates a different problem rather than solving the original one.

Summary

  • Use flatMap instead of map when the callback returns another Future.
  • Prefer for-comprehensions when several async steps need to be composed.
  • Use Future.sequence and Future.traverse for collections of futures.
  • Keep EitherT or OptionT composition inside the transformer and unwrap once at the edge.
  • Avoid blocking as a shortcut for flattening asynchronous structures.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.