Skip to main content
Version: 2.x

AssociativeFlatten

AssociativeFlatten[F] describes a way of combining two layers of a value of type F[F[A]] into a F[A] in a way that is associative.

Its signature is:

trait AssociativeFlatten[F[+_]] {
def flatten[A](ffa: F[F[A]]): F[A]
}

If we import zio.prelude._ we can use the flatten operator on any data type for which an AssociativeFlatten instance is defined to flatten a nested value F[F[A]] down to a single value.

This may appear to be a unary operator but it is actually a binary operator because there are two layers of F values here, the outer F value and the inner F value. The flatten operator combines these two layers of F values into a single value.

The flatten operator must be associative, so if we have three layers of nested values flattening the outer and the middle layers and then flattening the resulting layer with the inner layer must be the same as flattening the middle and the inner layers and flattening the outer layer with that.

fffa.flatten.flatten == fffa.map(_.flatten).flatten

This looks slightly different than the signatures of the other associative laws we have seen because of our need to map over the value to flatten it but it is conceptually the same as the associative laws for the Associative, AssociativeBoth, and AssociativeEither functional abstractions.

The flatten operator corresponds to running the outer value to produce a new value and then running that value.

We can see this by recognizing that at this point we know that F is covariant so it produces values of type A. We start with a value F[F[A]], which is essentially a producer of producers of values of type A.

So to get a final value F[A] that just produces A values we are going to need to run the outer F value to get those producers of A values, then run those producers of A values to get actual A values, and finally put the whole thing back together in the structure of F.

This turns out to be extremely powerful.

To see what this looks like let's start as we have before with ZIO.

A common mistake we might make when getting started with ZIO is accidentally creating a nested ZIO workflow like this:

import zio._

import java.io.IOException

val greet: ZIO[Random, Nothing, ZIO[Console, IOException, Unit]] =
Random.nextIntBounded(100).map { n =>
Console.printLine(s"The number is $n")
}
// greet: ZIO[Random, Nothing, ZIO[Console, IOException, Unit]] = FlatMap(
// trace = "repl.MdocSession.MdocApp.greet(associativeflatten.md:23)",
// first = Stateful(
// trace = "repl.MdocSession.MdocApp.greet(associativeflatten.md:23)",
// onState = zio.FiberRef$unsafe$$anon$2$$Lambda$17011/0x00000008046a1840@7b6aac03
// ),
// successK = zio.ZIO$$Lambda$17065/0x00000008046db040@312c9ea7
// )

This is very understandable.

We want to perform a second ZIO workflow, in this case printing to the console, based on the result of the first workflow. So we use the map operator, which is described by the Covariant abstraction, to get the success value of the first workflow and transform it with a function.

It is worth pointing out here that Covariant is the only abstraction in ZIO Prelude that describes the ability to transform the output of a covariant type with a function. So based on the structure described by the other functional abstractions in ZIO Prelude there is nothing we could do other than use map to make progress towards implementing this.

However, we are now faced with a problem. We have a ZIO workflow that produces another ZIO workflow instead of producing a value. This is normally not what we want.

This workflow is going to be difficult to compose with other workflows. If we want to get the result of the second workflow we are now going to have to use the map operator twice just to access the inner value.

In addition, running this workflow will probably not do what we intend, because it will just generate a random number and then return another workflow. We will not see anything printed to the console.

Conceptually what we would like to do is run this workflow to produce its result, which is another workflow that will actually print something to the console, and then run that workflow. However, we don't want to have to run these nested workflows ourselves and we want to be able to use this workflow in an ergonomic way without going through these multiple layers of nesting.

This is exactly what the flatten operator does for us. It takes a ZIO workflow that produces another workflow and flattens it down to a single workflow that conceptually runs the first workflow and then runs the resulting workflow.

Let's see how we can use the flatten operator to fix our code from above.

val greet: ZIO[Random with Console, IOException, Unit] =
Random.nextIntBounded(100).map { n =>
Console.printLine(s"The number is $n")
}.flatten
// greet: ZIO[Random with Console, IOException, Unit] = FlatMap(
// trace = "repl.MdocSession.MdocApp.<local MdocApp>.greet(associativeflatten.md:35)",
// first = FlatMap(
// trace = "repl.MdocSession.MdocApp.<local MdocApp>.greet(associativeflatten.md:33)",
// first = Stateful(
// trace = "repl.MdocSession.MdocApp.<local MdocApp>.greet(associativeflatten.md:33)",
// onState = zio.FiberRef$unsafe$$anon$2$$Lambda$17011/0x00000008046a1840@4e5f52c
// ),
// successK = zio.ZIO$$Lambda$17065/0x00000008046db040@5daa45ef
// ),
// successK = zio.ZIO$$Lambda$17066/0x00000008046dc040@39ebcbdf
// )

Now we have eliminated the nested workflows. This workflow will do exactly what we want, first generating a random number and then printing it to the console.

Of course if we are working with ZIO we quickly learn to use the flatMap operator, which just combines map and flatten.

val greet: ZIO[Random with Console, IOException, Unit] =
Random.nextIntBounded(100).flatMap { n =>
Console.printLine(s"The number is $n")
}
// greet: ZIO[Random with Console, IOException, Unit] = FlatMap(
// trace = "repl.MdocSession.MdocApp.<local MdocApp>.greet(associativeflatten.md:43)",
// first = Stateful(
// trace = "repl.MdocSession.MdocApp.<local MdocApp>.greet(associativeflatten.md:43)",
// onState = zio.FiberRef$unsafe$$anon$2$$Lambda$17011/0x00000008046a1840@13585a14
// ),
// successK = <function1>
// )

The flatMap operator is extremely convenient, especially in combination with Scala's syntax for for comprehensions. However, we are now in a position to see that there isn't really anything special about the flatMap operator itself.

The flatMap operator is just a combination of map and flatten and map is a very basic operator defined on all covariant data types. The real work here is in the flatten operator.

The flatten operator has to run one value to produce its output then run that output to produce a result.

In the case of ZIO this is at least conceptually relatively simple because a ZIO workflow always succeeds with exactly one value. So the interpretation of flatten is just to run the first workflow to produce its result and then if it is successful run that workflow.

In other cases the interpretation can be more complex.

For example, consider ZStream, which can succeed with zero or more values. In that case the interpretation of flattening a stream of streams is to concatenate the inner streams into a single stream.

Many other data types also support flatten operators, all of which have a similar interpretation of running the outer value to produce an inner value and then running the inner value.

Data types that model failure, such as Either and Option, are one example of this. Flattening a nested Either or Option corresponds to running the outer value and if it is a success running the inner value but otherwise failing immediately.

def flatten[E, A](eea: Either[E, Either[E, A]]): Either[E, A] =
eea match {
case Left(e) => Left(e)
case Right(ea) => ea
}
def flatten[A](ooa: Option[Option[A]]): Option[A] =
ooa match {
case None => None
case Some(oa) => oa
}

Collection types also have a flatten operator with a natural interpretation. If we think of running a collection as producing a set of values then flattening a collection corresponds to running a collection of collections and then running each of those collections to produce its values.

def flatten[A](aas: Chunk[Chunk[A]]): Chunk[A] =
aas.foldLeft[Chunk[A]](Chunk.empty)(_ ++ _)

Notice the similarity here to the interpretation of flatten for ZStream, although ZStream can be potentially effectual and resourceful.

The flatten operator is so powerful because it allows the next value to be produced by running the first value.

To see this, consider the following two ZIO workflows.

val helloAssociativeBoth: ZIO[Console, IOException, Unit] =
console.putStrLn("What's your name?") *>
console.readLine *>
console.putStrLn(s"Hello! Welcome to ZIO!")

val helloAssociativeFlatten: ZIO[Console, IOException, Unit] =
for {
_ <- console.putStrLn("What's your name?") *>
name <- console.readLine *>
_ <- console.putStrLn(s"Hello $name! Welcome to ZIO!")
} yield ()

The first workflow is written using only the structure described by the Covariant and AssociativeBoth abstractions.

We can use these abstractions to describe running one workflow and then running another workflow. So the first workflow will ask us for our name, then read our name, and then say hello.

However, notice that in the first workflow we can't greet the user by name. Although we can describe printing to the console after reading from the console, we can't actually use the value we read from the console in printing to the console.

Workflows described solely in terms of the Covariant and AssociativeBoth abstractions are static like this. We can compose them together but later workflows can't use information produced by prior ones.

This can have significant advantages in terms of opportunities for optimization. The first workflow can potentially be analyzed before being run, allowing us to determine what kinds of things it will do or a more efficient way of running it.

The second workflow is written using the structure described by the AssociativeFlatten abstraction.

Now we can describe a more powerful notion of running one workflow that produces a workflow and then running that workflow. Now we are able not only to print a greeting after reading from the console but to use the value we read from the console in the greeting.

Workflows written with the structure of AssociativeFlatten are much more flexible and allow building workflows of arbitrary complexity. This is why the flatMap operator is so useful when working with ZIO.

At the same time, this power limits our ability to introspect on workflows written using the flatten operator.

As we saw from our discussion at the beginning, we have to run the outer value to produce the inner value. So we don't know what the next workflow will be until we actually run the current workflow.

There are some ways we can increase the flexibility of workflows that don't use the structure provided by AssociativeFlatten.

For example the AssociativeEither abstraction allows us to describe a concept of failure.

So we could express some limited degree of conditionality, for example reading from the console again if reading the first time failed. However that would still not allow us to actually use the result from the previous workflow in the current one.

In some cases this more limited functionality can be acceptable. For example in parsing it can get us quite far, and as discussed above there are benefits to this approach.

But for general purposes programming the flexibility provided by the flatten operator is necessary to support the variety of things we may want to describe with our workflows.

As this discussion has hopefully made clear, AssociativeFlatten is a very important functional abstraction that gives us the ability to run a value that produces another value and then run that value, allowing us to describe workflows in a way that is extremely flexible.

If you are working with existing data types in ZIO or the Scala standard library they probably already define flatMap and flatten.

There are some operators defined on the ForEach and NonEmptyForEach abstractions that work with data types that have instances of AssociativeFlatten or IdentityFlatten. These can be helpful to do things like fold over a collection with the possibility of failure.

When defining your own parameterized data types the flatMap operator is so useful that you should be thinking about what an implementation of this abstraction would look like for your own data type. Or you may intentionally not want to expose it to allow for more static analysis, but that is definitely something worth thinking about.

For all the reasons discussed above the AssociativeFlatten and IdentityFlatten abstractions are also very important in writing generic code to express values that depend on other values in a parameterized context.