scalaadvanced

Cats Monad Transformers

Use Cats monad transformers: EitherT, OptionT, and StateT for composable effect stacks.

scala
import cats.*
import cats.data.*
import cats.syntax.all.*
import cats.effect.IO

// Problem: nesting Future[Either[Error, Option[T]]] gets ugly
// Solution: Monad transformers

type Result[A] = EitherT[IO, String, A]

case class User(id: Int, name: String, email: String)
case class Order(id: Int, userId: Int, total: Double)

def findUser(id: Int): Result[User] =
  if id > 0 then EitherT.rightT(User(id, s"User$id", s"user$id@test.com"))
  else EitherT.leftT(s"Invalid user id: $id")

def findOrders(userId: Int): Result[List[Order]] =
  EitherT.rightT(List(
    Order(1, userId, 99.99),
    Order(2, userId, 149.50)
  ))

def calculateDiscount(total: Double): Result[Double] =
  if total > 200 then EitherT.rightT(total * 0.1)
  else if total > 100 then EitherT.rightT(total * 0.05)
  else EitherT.rightT(0.0)

// Composed pipeline
def userDiscount(userId: Int): Result[String] =
  for
    user    <- findUser(userId)
    orders  <- findOrders(user.id)
    total   = orders.map(_.total).sum
    discount <- calculateDiscount(total)
  yield f"${user.name}: total=$$${total}%.2f, discount=$$${discount}%.2f"

// OptionT example
type MaybeIO[A] = OptionT[IO, A]

def findConfig(key: String): MaybeIO[String] =
  val configs = Map("host" -> "localhost", "port" -> "8080")
  OptionT.fromOption[IO](configs.get(key))

def parsePort(s: String): MaybeIO[Int] =
  OptionT.fromOption[IO](s.toIntOption)

def serverAddress: MaybeIO[String] =
  for
    host <- findConfig("host")
    portStr <- findConfig("port")
    port <- parsePort(portStr)
  yield s"$host:$port"

// StateT example
type GameState[A] = StateT[IO, Int, A]  // Int is the score

def addPoints(points: Int): GameState[Unit] =
  StateT.modify(score => score + points)

def getScore: GameState[Int] =
  StateT.get

def gameRound(correct: Boolean): GameState[String] =
  for
    _ <- if correct then addPoints(10) else addPoints(-5)
    score <- getScore
  yield s"Score: $score"

// Validated (not a monad, but accumulates errors)
case class FormData(name: String, age: Int, email: String)

def validateName(s: String): ValidatedNel[String, String] =
  if s.nonEmpty then s.validNel
  else "Name is required".invalidNel

def validateAge(s: String): ValidatedNel[String, Int] =
  s.toIntOption.filter(a => a >= 0 && a <= 150)
    .map(_.validNel)
    .getOrElse("Invalid age".invalidNel)

def validateEmail(s: String): ValidatedNel[String, String] =
  if s.contains("@") then s.validNel
  else "Invalid email".invalidNel

def validateForm(name: String, age: String, email: String): ValidatedNel[String, FormData] =
  (validateName(name), validateAge(age), validateEmail(email)).mapN(FormData.apply)

object App extends cats.effect.IOApp.Simple:
  def run: IO[Unit] = for
    result <- userDiscount(1).value
    _ <- IO.println(s"User discount: $result")

    error <- userDiscount(-1).value
    _ <- IO.println(s"Error case: $error")

    addr <- serverAddress.value
    _ <- IO.println(s"Server: $addr")

    // Validated accumulates ALL errors
    _ <- IO.println(validateForm("Alice", "30", "alice@test.com"))
    _ <- IO.println(validateForm("", "abc", "no-at"))  // 3 errors!
  yield ()

Use Cases

  • Composable error handling stacks
  • Optional value chaining
  • Form validation with error accumulation

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.