Skip to content

Instantly share code, notes, and snippets.

@ahoy-jon
Last active August 22, 2025 09:25
Show Gist options
  • Save ahoy-jon/0aec8bcf636fac096ae5e4b9ed706fe0 to your computer and use it in GitHub Desktop.
Save ahoy-jon/0aec8bcf636fac096ae5e4b9ed706fe0 to your computer and use it in GitHub Desktop.

The Rebirth of Tagless Final: An Ergonomic Approach in Kyo

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.

What is Tagless Final? A Quick Refresher

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.

The Kyo Journey: Searching for a Better Way

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.

Attempt 1: The Direct Translation

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.

Attempt 2: The Custom Effect Detour

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.

The Breakthrough: Tagless Final as a Kyo Effect

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.

Providing Implementations

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.

Conclusion: The Best of Both Worlds

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.

Further Reading and References

Thanks a lot to Pierre for the brain-melting OCaml/Scala discussions that sparked this idea!

@nightscape
Copy link

For clarity, you could put the definition of Both before the usage. I briefly thought this is something provided by the framework.

@ahoy-jon
Copy link
Author

ahoy-jon commented Aug 20, 2025

you could put the definition of Both

Yes, let's do that Removed Both, replaced it by LoggerAndConsole.

@ahoy-jon
Copy link
Author

ahoy-jon commented Aug 21, 2025

Quick note, given some feedback from different sources:

TL;DR, it's about Effect Polymorphism, not really Tagless Final. (thanks to Eric Torreborre on this one)

The Ergonomics of Tagless Final and HKTs

From a practical standpoint, the common Tagless Final (TF) pattern in Scala using Higher-Kinded Types (HKTs) presents two main challenges: a steep learning curve and significant syntactic overhead.

A typical TF signature mixes typeclass constraints, dependencies, and return types, which can be difficult to work with:

def something[F[_]: Monad, S1, S2](r: R): F[Either[E, A]]

In contrast, Kyo aims to unify these different kinds of requirements into a single, flat effect signature that is often easier to read:

val something: A < (Abort[E] & Env[R] & Use[S1] & Use[S2])

Here, the need for an environment (R), a potential error (E), and other services (S1, S2) are all treated as first-class effects at the same level.

So it's not about Tagless Final?

It's true that the proposed pattern isn't strictly Tagless Final. And Tagless Final in Scala is not as originally conceived either.
The original papers focused on providing extensible interpretations for embedded DSLs by preferring a "final" encoding (an interface) over an "initial" one (a concrete data structure).

// Final Encoding (the original idea)  
trait ExprSym[A]:
  def lit(i: Int): A
  def add(l: A, r: A): A

// Initial Encoding (the alternative)  
enum Expr:
  case Lit(value: Int)
  case Add(lhs: Expr, rhs: Expr)

In modern Scala, however, the technique is primarily used with F[_] to achieve polymorphism over effects (like IO or
Future). This is a powerful, specific application of the original, more general concept.

Isn't This Just the Reader Monad Again?

It's a fair question. At a high level, providing dependencies to a computation is the problem that the Reader monad (and its transformer, ReaderT) solves.

However, the goal here is to improve ergonomics. Kyo's Env[+A] effect provides a much cleaner experience. Instead of lifting computations into a ReaderT context, you simply add Env[A] to the effect signature. The Use[+R[-_]] effect proposed in this article can be seen as a polymorphic version of Env, designed specifically to handle service-like dependencies.

The "Vendor-Agnostic" Argument for F[_]

A common argument for using the F[_] pattern is that it keeps business logic independent of any specific effect library. While Kyo has its own effect type, it can still seamlessly interoperate with the classic F[_] style (thanks to Tagless Final).

Let's define our algebras using the standard F[_] pattern:

trait Console[F[_]]:
  def read: F[String]

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

trait Logger[F[_]]:
  def log(level: String)(message: String): F[Unit]

We can then write a Kyo program that requires these capabilities using a simple Ask effect. Notice how we can freely mix calls to the abstract console and logger with concrete Kyo effects:

val comp: String < (Ask[Console] & Ask[Logger] & Sync) =  
for
   // Get the required capabilities
  console <- Ask.get[Console]
  logger  <- Ask.get[Logger]

  // Use the capabilities  
  _       <- logger.log("Debug")("Start teletype example")
  _       <- console.printLine("Hello, World!")
  _       <- console.printLine("What is your name?")
  name    <- console.read
  _       <- console.printLine(s"Hello $name!")
    
  // Mix in a native Kyo effect
  _       <- kyo.Console.printLine("---")
  _       <- kyo.Console.printLine("Message from a native Kyo effect.")
yield name

Finally, at the "end of the world," we provide the concrete implementations and handle the Ask effects:

// Define an interpreter for Console that uses Kyo's Sync effect  
val soutConsole: Console[Ask.WithS[Sync]] = new Console[WithS[Sync]]:
  override def read: String < Sync = kyo.Console.readLine.orPanic

  override def printLine(line: String): Unit < Sync = kyo.Console.printLine(line)

// Define an interpreter for Logger  
val soutLogger: Logger[Ask.WithS[Sync]] = new Logger[WithS[Sync]]:
  override def log(level: String)(message: String): Unit < Sync = kyo.Console.printLine(s"[$level]: $message")

// Run the program by providing the interpreters  
object App extends KyoApp:
  run {
    comp.handle(
      Ask.run(soutLogger),
      Ask.run(soutConsole)
    )
  }

This demonstrates that you can adopt Kyo's ergonomic benefits without giving up the ability to work with the familiar and vendor-agnostic F[_] pattern.

(The full program is available as a gist here.

Future Directions

  • Autowiring: This pattern could be extended, similar to ZIO's layers, to automatically construct and provide dependencies, further reducing boilerplate.
  • Improved Cats-Effect Integration: While Cats-Effect programs can already run on the Kyo scheduler, these techniques could be refined to create even smoother integration tools for existing Tagless Final codebases.
  • Enhanced Type Safety for Nested Handlers: There's an opportunity to improve compiler guarantees for complex scenarios involving nested handlers for the same algebra. For example, the following code highlights a case where the order of operations matters, which could ideally be caught at compile time:
val prg: (String < Use[Console]) < Use[Console]

val console1: Console[S1]
val console2: Console[S2]

def handleS1[A, S](a: A < (S1 & S)): A < (S & S3)

// This program could lead to an unhandled S1 effect because `handleS1`  
// is called before the inner `Use[Console]` is handled.  
val handled: String < (S2 & S3) = prg.handle(
  Use.run(console1),
  handleS1, // Problem: This handler runs too soon
  _.flatten,
  Use.run(console2)
)

Moving handleS1 after Use.run(console2) would fix this specific case,
but future work could aim to make such ordering issues checked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment