Skip to content

Instantly share code, notes, and snippets.

@yisraelU
Forked from ahoy-jon/rebirth-of-tag-lessfinal.md
Created September 8, 2025 12:11
Show Gist options
  • Select an option

  • Save yisraelU/61fcac9593d7625c06b4d0c04d11bd3f to your computer and use it in GitHub Desktop.

Select an option

Save yisraelU/61fcac9593d7625c06b4d0c04d11bd3f to your computer and use it in GitHub Desktop.

2025-08-20 Original post.

2025-08-24 I'd like to refine the terminology used. The pattern demonstrated here is most accurately described as "polymorphism over effects, (using Higher-Kinded Types)".

It's important to distinguish this from other related concepts. Although it bears a strong resemblance to the Tagless Final pattern, this approach is distinct, and as a tight (not vendor free) integration with Kyo. Furthermore, please note that it is not "Effect Polymorphism," which is an alternate and less precise phrasing.

Thanks a lot to all of you for the valuable feedback!


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!

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