Tagless Final

Intro

If you've been working with Scala, especially in the pure functional programming world, you've probably heard of the tagless final pattern.

The tagless final pattern solves the difficulty of extending a data type with both new data variants and new operations on those data types without modifying existing code, while maintaining static type safety which is something called the Expression Problem.

Today we're going to demystify this whole thing using a relatable example, we’ll simulate a simple payment processing system. Because let's be honest, who hasn't dealt with payments in their coding career?

The problem that started it all

Imagine we are building a simple payment system, where we have multiple operations like bank transfers, credit card operations, wallet transfers.

Let’s start with a naive approach and see where that takes us

sealed trait Operation

case class CreditCardCharge(amount: Double, cardNumber: String) extends Operation
case class WalletTransfer(amount: Double, walletId: String) extends Operation
case class BankTransfer(amount: Double, accountNumber: String) extends Operation

def processOperation(op: Operation): String = op match {
  case CreditCardCharge(amount, cardNumber) => 
    s"Charged $amount to credit card ending in ${cardNumber.takeRight(4)}"
  case WalletTransfer(amount, walletId) => 
    s"Transferred $amount from wallet $walletId"
  case BankTransfer(amount, accountNumber) => 
    s"Transferred $amount from bank account $accountNumber"
}

here we just made a trait Operation and extended it for our three operations, notice how the return type of all three Operation is the same as the return type of processOperation, but is it always this simple?

When reality hits

This is when things get interesting, your Product Manager walks in while you’re sipping your coffee and goes “The fraud team wants risk scores as numbers and we need to audit transactions with timestamps”

Now our previous solution needs to return different types based on each operation

sealed trait Operation

case class CreditCardCharge(amount: Double, cardNumber: String) extends Operation
case class WalletTransfer(amount: Double, walletId: String) extends Operation
case class BankTransfer(amount: Double, accountNumber: String) extends Operation

// new operations
case class CalculateRiskScore(amount: Double, userId: String) extends Operation
case class TransactionLog(operation: String, userId: String) extends Operation

def processOperation(op: Operation): Any = op match {
  case CreditCardCharge(amount, cardNumber) => 
    s"Charged $amount to credit card ending in ${cardNumber.takeRight(4)}"
  case WalletTransfer(amount, walletId) => 
    s"Transferred $amount from wallet $walletId"
  case BankTransfer(amount, accountNumber) => 
    s"Transferred $amount from bank account $accountNumber"
  case CalculateRiskScore(amount, userId) =>
    if(amount > 1000) 8.5 else 2.5 // just dummy values
  case TransactionLog(operation, userId) =>
    AuditEntry(operation, userId, System.currentTimeMillis().toLong)
}

case class AuditEntry(operation: String, userId: String, timestamp: Long)

// compiles but will explode at runtime!
val result = processOperation(CalculateRiskScore(500, "user123"))
val description = result.asInstanceOf[String] // BOOOOOM, gotta be careful with the casts!

Do you see the difference? Now the return type of our processOperation is Any as the new operations now return one of the following String, Double or AuditEntry

This means

  • No type safety

  • Maintenance nightmare (good luck remembering what each operation do)

  • Wrong casts causing runtime exceptions

a slightly better approach would be adding the ‘result type’ itself to our operations

sealed trait Operation {
    def resultType: String
}

case class CreditCardCharge(amount: Double, cardNumber: String) extends Operation {
    override val resultType: String = "string"
}
case class WalletTransfer(amount: Double, walletId: String) extends Operation {
    override val resultType: String = "string"
}
case class BankTransfer(amount: Double, accountNumber: String) extends Operation {
    override val resultType: String = "string"
}

case class CalculateRiskScore(amount: Double, userId: String) extends Operation {
    override val resultType: String = "double"
}
case class TransactionLog(operation: String, userId: String) extends Operation {
    override val resultType: String = "log"
}

def processOperation(op: Operation): Any =  {
  val result = op match {
    case CreditCardCharge(amount, cardNumber) => 
        s"Charged $amount to credit card ending in ${cardNumber.takeRight(4)}"
    case WalletTransfer(amount, walletId) => 
        s"Transferred $amount from wallet $walletId"
    case BankTransfer(amount, accountNumber) => 
        s"Transferred $amount from bank account $accountNumber"
    case CalculateRiskScore(amount, userId) =>
        if(amount > 1000) 8.5 else 2.5 // just dummy values
    case TransactionLog(operation, userId) =>
        AuditEntry(operation, userId, System.currentTimeMillis().toLong)
    }

  op.resultType match {
    case "string" => result.asInstanceOf[String]
    case "double" => result.asInstanceOf[Double]
    case "log" => result.asInstanceOf[AuditEntry]
    case otherwise => throw new RuntimeException(s"Unexpected result type: $otherwise")
  }  
}

case class AuditEntry(operation: String, userId: String, timestamp: Long)

now we can at least check that we're getting the type what we are expecting but we still have some issues

  • We’re still using Any, no type safety

  • Type check happens on runtime

  • We have to manually write the return type for each new operation & cast everywhere

GADTs: A step toward type safety

Here we are going to use Generalized Algebraic Data Types (GADTs) to encode the return type in the operation itself

sealed trait Operation[A]

case class CreditCardCharge(amount: Double, cardNumber: String) extends Operation[String]
case class WalletTransfer(amount: Double, walletId: String) extends Operation[String]
case class BankTransfer(amount: Double, accountNumber: String) extends Operation[String]

case class CalculateRiskScore(amount: Double, userId: String) extends Operation[Double]
case class TransactionLog(operation: String, userId: String) extends Operation[AuditEntry]

def processOperation[A](op: Operation[A]): A = op match {
    case CreditCardCharge(amount, cardNumber) => 
        s"Charged $amount to credit card ending in ${cardNumber.takeRight(4)}"
    case WalletTransfer(amount, walletId) => 
        s"Transferred $amount from wallet $walletId"
    case BankTransfer(amount, accountNumber) => 
        s"Transferred $amount from bank account $accountNumber"
    case CalculateRiskScore(amount, userId) =>
        if(amount > 1000) 8.5 else 2.5 // just dummy values
    case TransactionLog(operation, userId) =>
        AuditEntry(operation, userId, System.currentTimeMillis().toLong)
}

case class AuditEntry(operation: String, userId: String, timestamp: Long)

// The compiler knows exactly what types these are
val riskScore: Double = processOperation[Double](CalculateRiskScore(500, "user123"))

// This won't even compile
val description: String = processOperation(CalculateRiskScore(500, "user123")) // Nope!

not only we got rid of the manual types now we can check for type error during compile time, however this can be improved further, instead of defining intermediate data structures then process them why not just deal with our final value?

Each time we create an operation, we can immediately compute and embed the final result. Our processing function becomes almost empty because all the work happens when we construct the operation, lets look at how this can be achieved

sealed trait Operation[A] {
    def value: A
}

case class CreditCardCharge(amount: Double, cardNumber: String) extends Operation[String] {
    override val value: String = s"Charged $amount to credit card ending in ${cardNumber.takeRight(4)}"
}

case class WalletTransfer(amount: Double, walletId: String) extends Operation[String] {
    override val value: String = s"Transferred $amount from wallet $walletId"
}

case class BankTransfer(amount: Double, accountNumber: String) extends Operation[String] {
    override val value: String = s"Transferred $amount from bank account $accountNumber"
}

case class CalculateRiskScore(amount: Double, userId: String) extends Operation[Double] {
    override val value: Double = if(amount > 1000) 8.5 else 2.5
}

case class TransactionLog(operation: String, userId: String) extends Operation[AuditEntry] {
    override val value: AuditEntry = AuditEntry(operation, userId, System.currentTimeMillis().toLong)
}

def processOperation[A](op: Operation[A]): A = op.value

case class AuditEntry(operation: String, userId: String, timestamp: Long)

val chargeResult: String = processOperation(CreditCardCharge(99.99, "4532123456789012"))
val riskScore: Double = processOperation(CalculateRiskScore(1500, "user789"))
val auditLog: AuditEntry = processOperation(TransactionLog("DUMMY_OPERATION", "user123"))

The Expression Problem

Here's where GADTs start to show their limitations. Adding a new operation will require doing the following

  1. Modify the sealed trait by adding a new case class

  2. Modify the processOperation function to handle the new case

Our code isn't open to extension without modification. Even worse, what if we want different ways to processOperations Maybe sometimes we want to:

  • Actually execute them (like we do now)

  • Log them for debugging

  • Validate them without execution

  • Convert them to JSON for an API

With our current approach, we'd need separate functions for each behavior, and every time we add a new operation, we'd have to update all of them.

This is exactly what the Expression Problem describes: we can easily add new operations (by extending the sealed trait), but we can't easily add new ways of processing them without modifying existing code.

Tagless Final

You're probably wondering:

“Okay, but where’s all that F[_] stuff I see in every tagless final video or article?”

Tagless Final solves this by representing operations as methods in a typeclass rather than data constructors (case classes).

The F[_] represents an effect type, it's a container that hold our result along with some computational context, here are a few examples

  • Option[String] - might contain a String, or might be empty

  • Future[String] - will eventually contain a String (asynchronously)

  • Either[Error, String] - contains either an error or a String

  • IO[String] - represents a computation that will produce a String when executed

By abstracting over the F[_] we separate between what our program does from how it does it, let’s see an example

import cats.Monad
import cats.syntax.flatMap._
import cats.syntax.functor._

trait PaymentOps[F[_]] {
  def chargeCard(amount: Double, cardNumber: String): F[String]
  def walletTransfer(amount: Double, walletId: String): F[String]
  def bankTransfer(amount: Double, accountNumber: String): F[String]
}

trait RiskOps[F[_]] {
  def calculateRiskScore(amount: Double, userId: String): F[Double]
}

trait AuditOps[F[_]] {
  def logTransaction(operation: String, userId: String): F[AuditEntry]
}

case class AuditEntry(operation: String, userId: String, timestamp: Long)

Now we have defined capabilities or behaviors instead of using data constructors, this let us write programs against these interfaces, an example would be making a chargeCard operation ONLY depending on the risk score

def riskAwarePayment[F[_]: PaymentOps: RiskOps: Monad](
  amount: Double,
  cardNumber: String,
  userId: String
): F[Either[String, String]] = {
  val paymentOps = implicitly[PaymentOps[F]]
  val riskOps = implicitly[RiskOps[F]]

  for {
    risk <- riskOps.calculateRiskScore(amount, userId)
    result <- if (risk > 7.0) {
      Monad[F].pure(Left("Payment rejected: high risk"))
    } else {
      paymentOps.chargeCard(amount, cardNumber).map(Right(_))
    }
  } yield result
}

if we opt to just make a normal payment without risk analysis we would just remove the RiskOps context from our payment and just leave the PaymentOps this gives us the advantage that we are using the least requirements for our operation to work.

Composition Magic

Let's say we need to add notifications to our payment system. With our old approach, we'd have to modify existing code. With tagless final, we just add a new typeclass.

Let’s actually add a new behavior, payments are often followed by a notification, so let’s go with that

import cats.Monad
import cats.syntax.flatMap._
import cats.syntax.functor._

trait PaymentOps[F[_]] {
  def chargeCard(amount: Double, cardNumber: String): F[String]
  def walletTransfer(amount: Double, walletId: String): F[String]
  def bankTransfer(amount: Double, accountNumber: String): F[String]
}

trait RiskOps[F[_]] {
  def calculateRiskScore(amount: Double, userId: String): F[Double]
}

trait AuditOps[F[_]] {
  def logTransaction(operation: String, userId: String): F[AuditEntry]
}

case class AuditEntry(operation: String, userId: String, timestamp: Long)

// new behavior
trait NotificationOps[F[_]] {
  def sendEmail(to: String, subject: String, body: String): F[Unit]
  def sendSMS(to: String, message: String): F[Unit]
}

// risk analysis payment WITH notification
def riskAwarePayment[F[_]: PaymentOps: RiskOps: NotificationOps: Monad](
  amount: Double,
  cardNumber: String,
  userId: String
): F[Either[String, String]] = {
  val paymentOps = implicitly[PaymentOps[F]]
  val riskOps = implicitly[RiskOps[F]]
  val notificationOps = implicitly[NotificationOps[F]]
  for {
    risk <- riskOps.calculateRiskScore(amount, userId)
    result <- if (risk > 7.0) {
      Monad[F].pure(Left("Payment rejected: high risk"))
    } else {
      paymentOps.chargeCard(amount, cardNumber).map(Right(_))
    }
    message = result match {
        case Left(err) => err
        case Right(_) => "Payment finished successfully"
    }
    _ <- notificationOps.sendSMS("user_phone_number", message)
  } yield result
}

We were able to extend riskAwarePayment with SMS notifications simply by adding NotificationOps[F] to the context bound, no changes to the core logic were necessary like before.

Notice now how this function doesn't know or care about:

  • How risk calculation actually works

  • How card charging actually works

  • What effect type F[_] actually is

It only knows that these capabilities exist and can be composed together.

Interpreters

Now we need to implement these behaviors somehow. An Interpreter provide a concrete implementation of our abstract capabilities using an effect type. Here is how we might implement it using cats.effect.IO and Option monad

import cats.effect.IO
import cats.Monad
import cats.syntax.flatMap._
import cats.syntax.functor._
import scala.util.Random

// interpreter using IO
implicit val ioPaymentOps: PaymentOps[IO] = new PaymentOps[IO] {
  def chargeCard(amount: Double, cardNumber: String): IO[String] = 
    IO.pure(s"Charged $$${amount} to credit card ending in ${cardNumber.takeRight(4)}")

  def walletTransfer(amount: Double, walletId: String): IO[String] = 
    IO.pure(s"Transferred $$${amount} from wallet $walletId")

  def bankTransfer(amount: Double, accountNumber: String): IO[String] = 
    IO.pure(s"Transferred $$${amount} from bank account $accountNumber")
}

implicit val ioRiskOps: RiskOps[IO] = new RiskOps[IO] {
  def calculateRiskScore(amount: Double, userId: String): IO[Double] = 
    IO.pure(if (amount > 1000) 8.5 else 2.5)
}

implicit val ioNotificationOps: NotificationOps[IO] = new NotificationOps[IO] {
  def sendEmail(to: String, subject: String, body: String): IO[Unit] = 
    IO.println(s"Email sent to $to: $subject")
  def sendSMS(to: String, message: String): IO[Unit] = 
    IO.println(s"SMS sent to $to: $message")
}

// interpreter using Option
// Note that Option is often for absence of value, this is probably not the
// place to use an option, but I am using it to demonstrate different monads
implicit val optionPaymentOps: PaymentOps[Option] = new PaymentOps[Option] {
  def chargeCard(amount: Double, cardNumber: String): Option[String] = 
    if (amount > 0) Some(s"Charged $$${amount}") else None

  def walletTransfer(amount: Double, walletId: String): Option[String] = 
    Some(s"Transferred $$${amount}")

  def bankTransfer(amount: Double, accountNumber: String): Option[String] = 
    Some(s"Transferred $$${amount}")
}

implicit val optionRiskOps: RiskOps[Option] = new RiskOps[Option] {
  def calculateRiskScore(amount: Double, userId: String): Option[Double] = 
    Some(Random.nextDouble() * 10)
}

Now we can run the same business logic with different interpreters:

// using the risk payment with 2 effect types :))
def riskAwarePayment[F[_]: PaymentOps: RiskOps: NotificationOps: Monad](
  amount: Double,
  cardNumber: String,
  userId: String
): F[Either[String, String]] = {
  val paymentOps = implicitly[PaymentOps[F]]
  val riskOps = implicitly[RiskOps[F]]
  val notificationOps = implicitly[NotificationOps[F]]
  for {
    risk <- riskOps.calculateRiskScore(amount, userId)
    result <- if (risk > 7.0) {
      Monad[F].pure(Left("Payment rejected: high risk"))
    } else {
      paymentOps.chargeCard(amount, cardNumber).map(Right(_))
    }
    message = result match {
        case Left(err) => err
        case Right(_) => "Payment finished successfully"
    }
    _ <- notificationOps.sendSMS("user_phone_number", message)
  } yield result
}

val ioResult: IO[Either[String, String]] = 
  riskAwarePayment[IO](500.0, "4532123456789012", "user123")

val optionResult: Option[Either[String, String]] = 
  riskAwarePayment[Option](500.0, "4532123456789012", "user123")

Conclusion

Tagless final isn't a silver bullet. It adds abstraction overhead and can make simple things complex, its like an investment in flexibility that pays off when requirements change, when you need to test complex integrations, or when you're building libraries that others will extend.

This pattern is particularly powerful in library design, where you want to provide functionality without dictating how users should handle effects. Personally, I’ve only used it in a side project and I can imagine how complicated it might get at scale. Like most abstractions, whether you should reach for it depends entirely on your use case.

The key insight is this: by abstracting over effect types with F[_], we separate what our program does from how it does it. This separation gives us the flexibility to adapt our programs to different requirements without rewriting our core business logic.

Next time you find yourself struggling with tightly coupled code, hard-to-test side effects, or the need to support multiple execution contexts, remember the tagless final pattern. It might just be the abstraction you need.

Resources

1
Subscribe to my newsletter

Read articles from Abdelrahman Ibrahim directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abdelrahman Ibrahim
Abdelrahman Ibrahim