For years, the Tagless Final pattern has been a cornerstone of functional programming in Scala, offering unparalleled compositionality and a clean separation between program definition and interpretation. Yet, for many, its power came at the cost of ergonomic friction: a world of F[_]
context bounds, implicit parameters, and boilerplate that could obscure the business logic it was meant to clarify.
I recently went on a fun journey with Kyo, a new effect system for Scala, and stumbled upon a discovery. After initially trying to replace Tagless Final with custom effects, I found a novel approach that doesn't kill the pattern but revitalizes it, making it more intuitive and human-friendly than ever before.
Tagless Final is a technique used for encoding programs with effects. Instead of using a concrete data type like IO[A]
, effects are abstracted over a type parameter, typically F[_]
. This allows you to write your program logic once and then provide multiple "interpreters" for different contexts (e.g., production, testing).
A key aspect of Tagless Final is defining capabilities as algebras (traits):
- Separation of Concerns: The algebra defines what can be done, not how.
- Composition: Different algebras can be combined using typeclasses (like cats.Monad).
- Extensibility: New interpreters can be added without changing the core logic.
Here is a classic example using Cats-Effect, defining Logger and Console:
import cats.Monad
import cats.implicits.* // for flatMap syntax
trait Logger[F[_]]:
def log(level: String)(message: String): F[Unit]
trait Console[F[_]]:
def read: F[String]
def printLine(line: String): F[Unit]
// The program requires Monad for sequencing and the capabilities via `using`.
def program[F[_]: Monad](using logger: Logger[F], console: Console[F]): F[String] =
for
_ <- logger.log("Debug")("Start teletype example")
_ <- console.printLine("Hello, World!")
_ <- console.printLine("What is your name?")
name <- console.read
_ <- console.printLine(s"Hello $name!")
yield name
This works beautifully but has well-known ergonomic costs: every function in the call stack needs to be parameterized by F[_]
and propagate the using parameters.
Kyo is an effect system that tracks effects in a type parameter S
, using intersection types (&) for composition. A value of type A < S
represents a computation that produces an A and has pending effects S.
A direct translation of the Tagless Final pattern into Kyo looks like this:
import kyo.*
// The effect type `S` replaces `F[_]`
trait Logger[-S]:
def log(level: String)(message: String): Unit < S
trait Console[-S]:
def read: String < S
def printLine(line: String): Unit < S
// The `using` clause remains, and `S` must be passed through.
def program[S](using logger: Logger[S], console: Console[S]): String < S =
for
_ <- logger.log("Debug")("Start teletype example")
_ <- console.printLine("Hello, World!")
_ <- console.printLine("What is your name?")
name <- console.read
_ <- console.printLine(s"Hello $name!")
yield name
This is a slight improvement, as Kyo's effect composition is more direct, but it doesn't solve the core ergonomic issue: the using
clause and the free S
parameter still clutter our business logic.
Kyo's power lies in defining custom, algebraic effects. My next thought was to ditch Tagless Final entirely and encode Log and Console as first-class Kyo effects.
import kyo.*
// 1. Define the effect signatures
sealed trait Log extends Effect[Const[Log.Line], Const[Unit]]
object Log:
case class Line(level: String, message: String)
def log(level: String)(message: String): Unit < Log =
Effects.suspend[Unit](Tag[Log], Line(level, message))
sealed trait Console extends Effect[Console.Op, Id]
object Console:
enum Op[+A]:
case Readline() extends Op[String]
case Printline(line: String) extends Op[Unit]
def printLine(line: String): Unit < Console = Effects.suspend(Tag[Console], Op.Printline(line))
def read: String < Console = Effects.suspend(Tag[Console], Op.Readline())
// 2. Define the program. Notice the clean, self-contained signature.
val program: String < (Log & Console) =
for
_ <- Log.log("Debug")("Start teletype example")
_ <- Console.printLine("Hello, World!")
_ <- Console.printLine("What is your name?")
name <- Console.read
_ <- Console.printLine(s"Hello $name!")
yield name
This is a huge step forward! The program is now a val with a concrete effect signature, String < (Log & Console)
, which explicitly states its dependencies. The logic is completely decoupled from its implementation.
However, defining full-blown effects like this is a powerful, lower-level feature of Kyo, often reserved for advanced users and library authors. It felt like overkill for simple dependency injection. This led to the final breakthrough.
Inspired by a discussion about dependency handling in OCaml, the solution became clear: What if the need for a dependency was itself another effect? (more powerful than kyo.Env
)
We can introduce a single, generic effect called Use[T]
. A computation with the effect Use[Console]
is one that requires an implementation of Console to be provided later.
With this, our program becomes breathtakingly simple:
import kyo.*
trait Logger[-S]:
def log(level: String)(message: String): Unit < S
trait Console[-S]:
def read: String < S
def printLine(line: String): Unit < S
// The program uses the `Use` effect to request its dependencies.
val program: String < (Use[Console] & Use[Logger]) =
for
// Get the instances from the environment
console <- Use.get[Console]
logger <- Use.get[Logger]
// Use them
_ <- logger.log("Debug")("Start teletype example")
_ <- console.printLine("Hello, World!")
_ <- console.printLine("What is your name?")
name <- console.read
_ <- console.printLine(s"Hello $name!")
yield name
This is the rebirth. We have a val with a self-documenting effect signature, String < (Use[Console] & Use[Logger])
, completely free of using clauses or free type parameters. The dependency requirement is now a first-class citizen of the effect system.
The final step is to "handle" the Use effect by providing concrete implementations. This is done at the "end of the world," completely separate from the logic.
First, we define our implementations (our interpreters):
trait LoggerAndConsole[-S] extends Logger[S], Console[S]
object sout extends LoggerAndConsole[Sync]:
override def read: String < Sync = Console.readLine.orPanic
override def printLine(line: String): Unit < Sync = Console.printLine(line)
override def log(level: String)(message: String): Unit < Sync = Console.printLine(s"[$level]: $message")
object noOp extends LoggerAndConsole[Any]:
override def read: String < Any = "Pierre"
override def printLine(line: String): Unit < Any = ()
override def log(level: String)(message: String): Unit < Any = ()
object noOpLog extends Logger[Any]:
override def log(level: String)(message: String): Unit < Any = ()
Now, we can run our program with different interpreters:
object App extends KyoApp:
// normal program
run:
Use.run(sout)(comp)
// noOp
run:
Use.run(noOp)(comp)
// handle Use[Log] first, then Use[Console] & Use[Log]
run:
comp.handle(
Use.run(noOpLog),
Use.run(sout))
The Use.run
function takes an implementation and a program requiring that implementation, and returns a new program where that dependency has been resolved and the effect S
added to the pending set.
This Use
effect pattern combines the strengths of Tagless Final with the ergonomic benefits of a structured effect system like Kyo.
Approach | Signature Clarity | Boilerplate | Composability |
---|---|---|---|
Classic TF | Fair (requires context) | High (F[_] , using) |
Good (via typeclasses) |
Custom Kyo Effect | Excellent (A < (Log & Console) ) |
Medium (effect definition) | Excellent (native) |
Kyo Use Effect | Excellent (A < Use[T] ) |
Low | Excellent (native) |
By treating dependency injection as a first-class effect, we've eliminated the ceremony that often made Tagless Final feel cumbersome. The logic is clean, the effect signatures are explicit, and the composition of both programs and their interpreters is seamless.
Tagless Final isn't dead. It just needed a new system to call home. With this pattern, it's not just a technique for library authors anymore. It could be a practical, powerful, and truly human approach to building modular applications.
- Complete Code Gist: The full, runnable source code for the Use effect pattern discussed in this article.
- Tagless Final for Humans by Noel Welsh: A great talk that explores making the Tagless Final pattern more approachable.
- The Death of Tagless Final by John De Goes: A great talk from 2021! On the criteria from this talk, this solution is 5A.
- Dependency Injection in OCaml: The article that inspired the Kyo
Use
effect, showing patterns in OCaml. - Original Kyo Example: The initial "direct translation" of Tagless Final into Kyo.
Thanks a lot to Pierre for the brain-melting OCaml/Scala discussions that sparked this idea!
Yes, let's do thatRemovedBoth
, replaced it byLoggerAndConsole
.