Skip to content

01 · Advanced Functional Programming

Level 3 built type classes and monoids by hand (Show, Monoid, Functor). Cats is the library that provides battle-tested versions of these — plus Monad, Validated, and dozens more — so production code doesn't reinvent them per project. This module introduces Cats' core abstractions and Validated, the accumulating alternative to Either.

build.sbt
scalaVersion := "3.3.1"
libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"

Monad: the pattern behind for-comprehensions

A Monad[F[_]] is a Functor (module 04, Level 3) that also has flatMap and pure (wrap a plain value). It's the exact shape List, Option, Either, and Future all already have — Cats just names it and provides one shared interface:

trait MyMonad[F[_]]:
  def pure[A](a: A): F[A]
  def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]

given MyMonad[Option] with
  def pure[A](a: A): Option[A] = Some(a)
  def flatMap[A, B](fa: Option[A])(f: A => Option[B]): Option[B] = fa.flatMap(f)

println(summon[MyMonad[Option]].flatMap(Some(5))(x => Some(x * 2)))   // Some(10)

Cats' real cats.Monad[F[_]] is this same shape, already instanced for Option, List, Either, Future, and more — importing cats.implicits._ brings all of it (plus extension methods like .mapN below) into scope.

Either chains stop at the first error — Validated doesn't

Level 2's Either-based validation (recall makeUser from the Option/Either module) short-circuits: the first Left wins, and later checks never even run. cats.data.Validated fixes exactly this — it accumulates every failure instead of stopping at the first:

import cats.data.Validated
import cats.data.Validated.{Valid, Invalid}
import cats.implicits._

def validateName(name: String): Validated[List[String], String] =
  if name.isBlank then Invalid(List("name cannot be blank")) else Valid(name)

def validateAge(age: Int): Validated[List[String], Int] =
  if age < 0 then Invalid(List("age cannot be negative")) else Valid(age)

case class User(name: String, age: Int)

def makeUser(name: String, age: Int): Validated[List[String], User] =
  (validateName(name), validateAge(age)).mapN(User.apply)

println(makeUser("Ada", 30))   // Valid(User(Ada,30))
println(makeUser("", -5))      // Invalid(List(name cannot be blank, age cannot be negative))

makeUser("", -5) reports both problems — exactly the gap the Level 2 module flagged as a known limitation of plain Either. The trick is .mapN: it doesn't chain like flatMap (which requires each step to know the previous succeeded); it runs every validation independently first, then combines the results, collecting failures from a List (any type with a Semigroup — module 04's monoid idea, minus the empty requirement) along the way.

The trap: Validated isn't a Monad, and that's the point

Validated deliberately has no flatMap in the way Either does — if it did, step 2 would depend on step 1 succeeding, which is exactly the short-circuiting behavior Validated exists to avoid. Reach for Either (or its Cats-flavored EitherT) when later steps genuinely need earlier results and stopping early on failure is correct; reach for Validated when checks are independent and you want every error reported at once — the classic case being form/request validation.

mapN: combining independent effects

.mapN works on any tuple of the same effect type — not just Validated:

val opt = (Option(1), Option(2)).mapN(_ + _)
println(opt)   // Some(3)

If either Option were None, the whole result is NonemapN combines results only when every input succeeded, same spirit as Future.sequence from Level 3 but for a fixed, small number of heterogeneous inputs rather than a list of the same type.

Cats Effect: IO as a purer Future

cats-effect's IO[A] describes an effectful computation without running it immediately — unlike Future, which starts running the moment it's created, an IO is inert until something runs it (typically IOApp's run at the very edge of your program). This separation ("describe the computation" vs. "run the computation") is what makes IO-based code easier to test and reason about — you can build up an entire program as a value and only decide when it executes at one point:

import cats.effect.{IO, IOApp}

object Main extends IOApp.Simple:
  def run: IO[Unit] =
    for
      _ <- IO.println("starting")
      a <- IO(21)
      b <- IO(21)
      _ <- IO.println(s"sum: ${a + b}")
    yield ()
starting
sum: 42

How It Actually Works

A Monad[F[_]] is mechanically nothing beyond a Functor (from Level 3) plus flatMap and pure (wrap a raw value), and for-comprehensions are literally defined by the compiler as syntax that desugars to flatMap/map calls — there's no independent "monad" concept the compiler understands; it just requires whatever type you use in a for to have methods named flatMap and map with compatible signatures (this is structural, not nominal: your type doesn't need to extend any Monad trait for for to work on it, it just needs the right method shapes). Why Validated deliberately isn't a monad comes down to flatMap's own signature: flatMap must decide what to do based on the result of the previous step before it can even construct the next one, which structurally forces short-circuiting on failure — there's no way to write a lawful flatMap for Validated that also accumulates multiple independent failures, because by the time flatMap is called, only one failure (or success) is available to inspect. mapN sidesteps this by not chaining at all: it takes several already-computed Validated values up front and combines them with a function that only runs if all are Valid, so failures from independent steps can all be accumulated by Validated's own combine logic (a Monoid-like operation) before any short-circuiting would need to happen.

Cats Effect's IO[A] fixes a subtlety Future has by construction: Future(sideEffect()) runs sideEffect() eagerly, the instant the Future is constructed, on whatever ExecutionContext is implicitly in scope — meaning a val binding a Future has already started work before anything reads it. IO instead compiles down to a data structure describing a computation (a tree of suspended thunks and combinators, not a running computation) and does nothing until you explicitly call .unsafeRunSync() or hand it to the Cats Effect runtime — which is why IO is referentially transparent (an IO value can be passed around, reused, or discarded with fully predictable behavior, never triggering a side effect just by existing) while a Future value already represents work in flight the moment you have one.

Cheat sheet

Concept What it gives you
cats.Monad[F[_]] Shared pure/flatMap interface across Option/List/Either/Future/etc.
Validated[E, A] Either-like, but accumulates every error instead of stopping at the first
.mapN Combine several independent effects (Validated, Option, ...) into one result
IO[A] A description of an effect, run explicitly (usually via IOApp), unlike eager Future
Reach for Either/EitherT When step N genuinely needs step N-1's result
Reach for Validated When checks are independent and you want every failure reported

Exercise

Extend makeUser with a third check, validateEmail(email: String): Validated[List[String], String] (require it contains "@"), and combine all three with .mapN into a User(name, age, email). Confirm passing all three invalid values reports all three error messages together. Then write a small IO-based program using IOApp.Simple that reads two IO[Int] values (simulate with IO(42) and IO(8)), computes their .mapN(_ + _) using cats.implicits._, and prints the result — note IO also supports .mapN the same way Option and Validated do, because it's a Monad too.