If you follow the Scala community, you’ve probably heard about John de Goes’s talk “Death of Final Tagless”. If you haven’t, no worries — it’s available online both as a video & a blog post — and I definitely recommend watching it. The talk starts with a gentle and very clearly presented motivation on why to use functional effects in the first place. It shows that John is an experienced teacher! But more than that, it’s one of those talks which make you rethink some of the fashions in our industry and assumptions you’ve held so far.

The talk contains a fair amount of critique of the so-called “Final Tagless” encoding. However, this construct can be used for two purposes: constraining effect wrappers and tracking effects in detail. In John’s talk these are covered together, but they deserve separate treatment and separate criticism.

Final Tagless as a way to constrain effect wrappers

As John mentions in the beginning of his talk, the idea of “programming to an interface” (rather than to a concrete implementation) is well-understood and used pervasively in languages such as Java and Scala. I think we’re all used to defining the interface of a service, and making our code dependent on some implementation of that interface.

Repeating John’s Console example, we start with a side-effecting version, where the console service is passed as a constructor parameter to its usage site:

trait Console {

def putStrLn(line: String): Unit

def getStrLn: String

} object LiveConsole extends Console {

def putStrLn(line: String): Unit = println(line)

def getStrLn: String = scala.io.StdIn.readLine()

} class Main(console: Console) {

def run(): String = {

console.putStrLn("Good morning, what’s your name?")

val name = console.getStrLn

console.putStrLn(s"Great to meet you, $name")

name

}

}

The first step to improve this code is to introduce some control over side effects, by wrapping them in an IO datatype. This is well motivated both in John’s talk and on various other blogs, so I won’t repeat the argument here. We end up with the following code:

trait Console {

def putStrLn(line: String): IO[Unit]

def getStrLn: IO[String]

} object LiveConsole extends Console {

def putStrLn(line: String): IO[Unit] = IO.effect(println(line))

def getStrLn: IO[String] = IO.effect(scala.io.StdIn.readLine())

} class Main(console: Console) {

def run(): IO[String] = {

for {

_ <- console.putStrLn("Good morning, what’s your name?")

name <- console.getStrLn

_ <- console.putStrLn(s"Great to meet you, $name")

} yield name

}

}

Note that our Main.run method now builds a data structure which describes effectful operations. Side-effects are contained, we can clearly see which methods have side effects (the ones that use IO ) and which don’t, and we can use the IO values without fear in a referentially-transparent way.

So far so good; no Final Tagless in sight!

Can we improve further? Well, the IO datatype is quite rich. It can describe sequential computations, wrap side effects, run computations in parallel, race two computations nondeterministically, etc. We might want to restrict this to know — just by looking at the signature of a method or class — what kind of operations can be performed on our chosen side-effects wrapper.

The problem here is that we are using a concrete implementation of IO , instead of programming to an interface! Let’s fix this. We need to somehow express that our code is dependent on a wrapper with a specific interface, instead of using a concrete implementation. We’ll start by parametrising our interface with “some” abstract wrapper which we will call F :

trait Console[F[_]] {

def putStrLn(line: String): F[Unit]

def getStrLn: F[String]

}

In LiveConsole and Main we are interacting with the F wrapper by calling methods on it (e.g. flatMap in Main.run ’s for -comprehension), so we need something more: a way to express the fact the the F should support a given set of methods. We’ll do this by introducing a dependency to LiveConsole and Main .

However, working with an effect wrapper (either abstract or concrete) is quite different from working with a regular service. Hence, we are going to introduce that dependency in a way which makes it easy to work with.

In Scala, this is done by adding an implicit parameter (instead of a regular one) which gives capabilities to the F effect wrapper. This is then used by extension methods, which are available on a type, if a specific implicit value is in scope.

What kind of dependencies do we need to introduce? For Main , we only need sequential composition: describing an operation which first runs one effectful computation, and then a second one. This is what a Monad gives us. For LiveConsole , we need a way of wrapping side-effecting computations. This is what Sync from cats-effect (if we are using cats) represents. Our code now becomes ( class Main[F[_]: Monad] is just a shorthand notation for class Main[F[_]](implicit fm: Monad[F]) ):

trait Console[F[_]] {

def putStrLn(line: String): F[Unit]

def getStrLn: F[String]

} class LiveConsole[F[_]: Sync] extends Console[F] {

def putStrLn(line: String): F[Unit] =

Sync[F].effect(println(line))

def getStrLn: F[String] =

Sync[F].effect(scala.io.StdIn.readLine())

} class Main[F[_]: Monad](console: Console[F]) {

def run(): F[String] = {

for {

_ <- console.putStrLn("Good morning, what’s your name?")

name <- console.getStrLn

_ <- console.putStrLn(s"Great to meet you, $name")

} yield name

}

}

And that’s the whole idea behind Final Tagless — instead of using concrete effectful wrapper, we declare what kind of interface is needed for the wrapper in a particular class or method.

Final Tagless as a way to track effects

We can extend the idea presented above to track what kind of side effects our code uses in more detail. Scala gives us quite a wide range of possibilities here. The only question is: how fine-grained the effect tracking should be?

Of course: it depends!

First, we have the option to track no effects at all. That’s what we’ve seen in the very first code snippet: the LiveConsole implementation was just doing uncontrolled and unconstrained side effects (in this case, printing/reading from the console).

Improving on this, we can track side effects at a binary level: “has effects” or “has no effects”. Looking at a method signature, we know if it’s declared as being pure (e.g. f: List[User] => Statistics ) or if it is declared to have side effects ( f: List[User] => IO[SentEmails] ).

Making this jump, from tracking no effects at all, to a has effect/no effect distinction is what makes the biggest difference in most code bases. And in many cases you can stop here.

However, if you want to, you can go further, and track in the method signatures what kind of effects exactly does a method use. IO means “some effect”, while you could want to know — does it mean using a database? Sending emails? Interacting with the console?

That’s what various effect systems in Scala want to solve. And that’s also what ZIO Environment is about. And it’s not the only proposed possibile solution; contenders in this space include:

the reader monad

final tagless

free monads

implicit function types in Dotty/Scala 3

and now ZIO Environment

As a side note: ZIO Environment is not about “injecting dependencies”. Dependency injection and tracking effects are distinct things. The first one, dependency injection, is about creating a static object graph (module graph), where the dependencies are hidden from the use sites. Effect tracking is about making dependencies explicit to the use sites; dependencies become part of the interface. That’s why the reader monad is not an alternative to dependency injection, but can be a complement of it.

How to extend Final Tagless to track effects? Just as John has shown in his example. Instead of passing Console[F] as a constructor parameter to Main , we require it as another constraint for our effect wrapper F on the method:

trait Console[F[_]] {

def putStrLn(line: String): F[Unit]

def getStrLn: F[String]

}

object Console {

def apply[F[_]](implicit F: Console[F]): Console[F] = F

} class LiveConsole[F[_]: Sync] extends Console[F] {

def putStrLn(line: String): F[Unit] =

Sync[F].effect(println(line))

def getStrLn: F[String] =

Sync[F].effect(scala.io.StdIn.readLine())

} object Main {

def run[F[_]: Monad: Console](): F[String] = {

for {

_ <- Console[F].putStrLn("Good morning, what’s your name?")

name <- Console[F].getStrLn

_ <- Console[F].putStrLn(s"Great to meet you, $name")

} yield name

}

}

While before the console dependency was hidden from Main ’s users, now it is explicit. The dependency must be provided by the caller of the method, not when creating the object graph! Hence, each use-site also needs to have the Console dependency, and so on.

We are now clear that Main.run has side effects (as it uses an effect wrapper at all) and additionally what kind of side-effects exactly (it interacts with the console).

Note that the : Sync and : Monad constraints are different in their nature from the : Console constraint. The first ones describe the capabilities that the F effect wrapper has, and form lawful, “true”, typeclasses. The second doesn’t say anything new about F , it just constraints the possible side-effects. Such constraints shouldn’t be considered a typeclass in the first place (which is also one of John’s points).

Is Final Tagless a good way to track effects?

It might be —or it might be not. All of the criticism of Final Tagless from John’s talk is of course valid, however it applies only to using Final Tagless for effect tracking — not constraining effect wrappers!

Is ZIO Environment the answer? It might be — if you need to track effects in your application! Or, it might as well be the case that just knowing if a function has side effects or not is sufficient; that is, using “plain old” IO[_] (or an abstract F[_] ).

We all know what are the shortcomings of Final Tagless, thanks to John’s excellent talk. But we still have to wait for field reports of using ZIO Environment in real projects. Some of the potential shortcomings have already been pointed out by Oleg Nizhnik, so I will refer you to this twitter thread instead of repeating them here.

What I would add, is that the necessity to use the cake pattern (let’s call it what it is :) ) is quite an intrusive change and might require adapting your codebase. It’s not as simple as just using a “plain old” class with methods.

Do you have to be a functional programming expert to understand all this?

No. As John says in his talk, you don’t need to understand what a trifunctor is, why ZIO[R, E, A] forms a profunctor on its R / E and R / A type parameter pairs, or what a profunctor even is to use ZIO. I would argue that the same is true for Final Tagless: to use it as a way to constrain effect wrappers, you don’t need to have a thorough understanding of type classes, higher kinded types or the Monad typeclass hierarchy.

It’s sufficient to understand why coding to an interface, instead of an implementation is preferable. And being open to alternative ways of expressing dependencies (in this case: not as a parameter, but as an implicit type constraint).

It’s a very interesting quest to find out how these concept generalise; what a typeclass is; how pervasive monads are; how applicatives differ from monads; how free is equivalent to tagless final; what’s a profunctor; etc. But that’s not necessary to start using them.

Fashion?

Final Tagless is definitely a victim of hype and fashion. It shouldn’t be used for everything, as that’s how we end up with monstrosities such as we’ve seen in John’s talk:

def genFeed[F[_]: Monad:

Logging: UserDatabase:

ProfileDatabase: RedisCache:

GeoIPService: AuthService:

SessionManager: Localization:

Config: EventQueue: Concurrent:

Async: MetricsManager]: F[Feed] = ???

A couple of years ago Free Monads were the fashionable thing to use in the functional programming community. Using them as the main way to structure programs turned out to be cumbersome, the amount of boilerplate needed greatly exceeded benefits they bring.

However, Free Monads have found their niche. They turn out to be a very good choice for representing general-purpose abstractions. For example, when describing database operations ( ConnectionIO from Doobie or DBIOAction from Slick), or when describing concurrent programs ( IO in ZIO, Task in Monix, Behavior in Akka-Typed). For these kinds of abstractions, it’s just much more convenient to work with a value-based representation.

Yes, IO / Task itself is another realisation of the “code to an interface” idea. When describing computation using IO, you create a description of the computation (as a value), using IO’s primitives. Only later that is being interpreted, that is, the IO primitives are given specific meaning. In theory, you can have multiple IO interpretations, which correspond to multiple interface implementations.

The niche for Final Tagless is constraining effect wrappers. But not — as John’s talk shows quite well — tracking effects in detail in an application.

I’m quite sure a niche will emerge for ZIO Environment. Such as situations were you need detailed effect tracking.

Wrapping up

All tools have their proper use. Neither Final Tagless, Free Monads, constructor-based dependency injection nor ZIO Environment should be used for everything.

There’s many ways in which you can realise the “code to an interface” idea in Scala. In each of them, you have a basic “set of instructions” which you use to build your application logic. These instructions can take various forms:

calling methods on dependencies passed as parameters (constructor based dependency injection)

final tagless: dependencies are passed as implicit capabilities

free monads: instructions are represented as values

reader monad: a convenient way of passing a single dependency

Summing up, what ZIO Environment is: an interesting combination of an optimized reader monad with the cake pattern. What Final Tagless isn’t: dead.