Scala has no Monad trait. That single fact explains most of the confusion around the word: the language compiles for-comprehensions into flatMap and map calls by name, before typing, and never checks that the receiver is a monad in any formal sense. So Option, Either, Try, List and Future all work in a for block, but only some of them obey the laws that make refactoring safe. This page takes the abstraction apart against Scala 3.3: the actual interface, the three laws stated precisely and checked, what each everyday instance short-circuits on, where Try and Future break, and where Cats and ZIO supply what the standard library declines to.

The interface is three method names, not a trait

A monad, operationally, is a type constructor M[_] plus two operations: a way to lift a plain value in, and a way to sequence a function that produces another M. In Haskell those are return and >>=. In Scala they are whatever the type happens to call them, because there is nothing to implement.

// Illustrative only -- NO such trait exists in the Scala standard library.
trait M[A]:
  def flatMap[B](f: A => M[B]): M[B]
  def map[B](f: A => B): M[B]

object M:
  def unit[A](a: A): M[A]     // spelled Some, Right, Success, List, Future.successful

// map is redundant in principle:
//   m.map(f)  ==  m.flatMap(a => unit(f(a)))
// The library defines it directly anyway, because the desugaring names it.

The lift operation, usually written unit or pure, is not a method on the value at all -- it is a constructor, and every type spells it differently: Some(a), Right(a), Success(a), List(a), Future.successful(a). That is why no common supertype could capture it even if someone wanted to add one; a trait can declare flatMap but not a companion constructor.

map is derivable from the other two, and stating the derivation is worth the line because it is the reason map and flatMap always agree: m.map(f) must equal m.flatMap(a => unit(f(a))). The standard library defines map directly for speed, and the for desugaring requires it to exist by name, so a type offering only flatMap will compile until you write a yield and then fail on the last generator. The set of names the compiler may reach for is flatMap, map, withFilter and foreach -- four methods, no inheritance, no evidence parameter.

Advertisement

The three laws, and what they buy

The laws are equations between values. Nothing in the compiler checks them, no test runs unless you write one, and a type that violates them still compiles and still works in a for-comprehension. They matter because they are precisely the licences you rely on when refactoring.

// 1. Left identity     unit(a).flatMap(f)  ==  f(a)
// 2. Right identity    m.flatMap(unit)     ==  m
// 3. Associativity     m.flatMap(f).flatMap(g)  ==  m.flatMap(a => f(a).flatMap(g))

// Checked against Option, with unit = Some:
val f: Int => Option[Int] = n => if n > 0 then Some(n * 2) else None
val g: Int => Option[Int] = n => Some(n + 1)

Some(3).flatMap(f)                      // Some(6) -- and f(3) is Some(6)
Some(3).flatMap(Some(_))                // Some(3);  None.flatMap(Some(_)) is None
Some(3).flatMap(f).flatMap(g)           // Some(7)
Some(3).flatMap(a => f(a).flatMap(g))   // Some(7)

Read each law as a refactoring you already perform without thinking. Left identity says binding a value you just wrapped is the same as using the value directly -- it licenses inlining a val into the expression that uses it. Right identity says for { a <- m } yield a is just m, which licenses deleting a pass-through step. Associativity is the important one: it says regrouping the binds does not change the result, which is exactly what lets you lift three lines out of the middle of a for-comprehension into a named helper that returns M[B], and call it, and get the same answer.

Option satisfies all three by construction. Some(a).flatMap(f) is defined as f(a) and None.flatMap(_) is None, so both sides of every law reduce to the same expression by case analysis on two constructors. That is what a well-behaved instance looks like, and it is the baseline against which the misbehaving ones in the next sections should be read.

How a for-comprehension desugars

The rewriting happens in the parser-adjacent phase, on untyped trees. The compiler does not know what e is when it rewrites for (x <- e) yield r into e.map(x => r); ordinary member resolution runs afterwards and either finds a suitable map or does not. The rules are short enough to memorise.

for (x <- e) yield r            ==>  e.map(x => r)
for (x <- e) body               ==>  e.foreach(x => body)      // no yield
for (x <- e; rest) yield r      ==>  e.flatMap(x => for (rest) yield r)
for (x <- e if p; rest) ...     ==>  the generator becomes e.withFilter(x => p)

// Nothing above mentions a type class. Any type with the right method names works:
final case class Box[A](value: A):
  def map[B](f: A => B): Box[B] = Box(f(value))
  def flatMap[B](f: A => Box[B]): Box[B] = f(value)

for
  a <- Box(1)
  b <- Box(a + 1)
yield a + b        // Box(3) -- the compiler never asked whether Box obeys anything
for { a <- fa ; b <- f(a) } yield g(b)what you writerewritten by name, before typingfa.flatMap(a => f(a).map(b => g(b)))no trait is consulted; only the method names must resolveOption[A]None halts andsays nothingEither[E, A]Left(e) haltsand says whyTry[A]Failure halts;flatMap catchesList[A]branches onceper elementFuture[A]already running;failure haltsThe standard library ships the instances but not the abstraction. Cats and ZIO supply that.
A for-comprehension is rewritten into flatMap and map by name alone. What differs between the everyday instances is only what each one short-circuits on.

Three consequences follow from the rewriting being purely syntactic. First, the methods can come from anywhere resolution reaches, including extension methods and implicit conversions -- Cats works precisely by supplying flatMap syntax for types that lack it. Second, mixing types across generators usually fails, and the reason is resolution rather than a rule: the first generator fixes the receiver, and every later generator must produce something that receiver's flatMap will accept. Binding an Option and a Future in one block therefore does not compile, while binding a List and then an Option does, because List.flatMap takes an IterableOnce and an implicit conversion makes an Option one. Third, a for-comprehension is evidence of nothing. It proves the names resolved, not that any law holds.

Guards and refutable patterns route through withFilter, which is a lazy variant of filter that avoids materialising an intermediate collection. A type can therefore support for-comprehensions perfectly well and fail the moment you add an if. The wider behaviour of guards, value definitions inside the block and yield-less loops belongs to for-comprehensions; what matters here is that the desugaring names methods and asks no questions.

Option — short-circuiting on absence

Option[A] is the smallest interesting instance: two constructors, one of which carries a value. Its flatMap short-circuits on None, so a chain of steps that may each fail to produce a value reads as straight-line code with no null checks and no nesting.

def parseInt(s: String): Option[Int] = s.toIntOption
def reciprocal(n: Int): Option[Double] = if n == 0 then None else Some(1.0 / n)

def parseReciprocal(s: String): Option[Double] =
  for
    n <- parseInt(s)
    r <- reciprocal(n)
  yield r

parseReciprocal("4")    // Some(0.25)
parseReciprocal("0")    // None
parseReciprocal("cat")  // None -- indistinguishable from the line above

Option(null)  // None      -- Option.apply is a null check, not unit
Some(null)    // Some(null) -- Some is unit

What Option deliberately does not do is say why. None is a single value, so a failed parse and a division by zero produce literally the same result and the caller cannot tell them apart. That is not a defect -- it is the design point, and it is the entire argument for reaching for Either the moment a caller would want to react differently to two kinds of failure.

One trap is worth naming because it costs people an afternoon. Option.apply is not unit: it inspects its argument and returns None for null. Some.apply is unit, and Some(null) is a perfectly good Some holding a null reference. The idiomatic use of Option(...) is exactly at a Java boundary, wrapping something that may return null; using it internally on values you constructed hides a null check you did not intend. Option also carries withFilter, so guards work, and it exposes collection-shaped methods (foreach, toList, exists) that let it stand in for a zero-or-one collection. Details in Scala Option.

Either — short-circuiting on the left

Either[A, B] carries a value on both sides, and since Scala 2.12 it is right-biased: map and flatMap live directly on Either and operate on the Right case. Older code that writes e.right.flatMap(...) predates that change, when you had to select a projection before you could compose at all.

sealed trait AppError
case object NotAnInt     extends AppError
case object DivideByZero extends AppError

def parseInt(s: String): Either[AppError, Int] =
  s.toIntOption.toRight(NotAnInt)

def reciprocal(n: Int): Either[AppError, Double] =
  if n == 0 then Left(DivideByZero) else Right(1.0 / n)

def parseReciprocal(s: String): Either[AppError, Double] =
  for
    n <- parseInt(s)
    r <- reciprocal(n)
  yield r

parseReciprocal("cat")  // Left(NotAnInt)     -- the chain stops and says why
parseReciprocal("0")    // Left(DivideByZero)

// The signature widens the left side:
//   def flatMap[A1 >: A, B1](f: B => Either[A1, B1]): Either[A1, B1]

The signature detail matters in real codebases. flatMap is declared with a widened left parameter, so chaining two Eithers whose error types are unrelated infers their least upper bound -- frequently Any, which type-checks and is useless. The fix is the one shown above: a sealed error hierarchy for the module, so every step in a chain contributes a member of the same ADT and the inferred left type stays meaningful. This pairing of Either with a sealed trait of domain errors is the standard Scala error-handling idiom, and it is why case classes and pattern matching show up in every error-handling discussion.

Filtering is where Either differs structurally from Option and List, and the reason is worth stating rather than memorising: Either has no empty case. There is no value that a filtered-out Right could become, so any filter must be told what Left to produce -- which is what filterOrElse(p, orElse) is for. Conversions in both directions are cheap: toRight and toLeft lift an Option by supplying the missing side, and toOption discards the error. See Scala Either.

Try — the everyday instance that breaks a law

Try[A] is Success(a) or Failure(t: Throwable), and Try { ... } takes its argument by name and catches anything NonFatal thrown while evaluating it. That is exactly what you want at the boundary of a Java library. It is also, precisely, what breaks left identity.

import scala.util.{Try, Success, Failure}

val boom: Int => Try[Int] = _ => throw new RuntimeException("boom")

Try(1).flatMap(boom)   // Failure(java.lang.RuntimeException: boom)
boom(1)                // throws java.lang.RuntimeException: boom

// Left identity says these two must be the same value. They are not, because
// Success#flatMap wraps the call to f in a NonFatal catch. Try's flatMap is
// strictly more forgiving than the law allows -- deliberately.

Try(riskyJavaCall())            // catches NonFatal; rethrows OutOfMemoryError etc.
  .toEither                     // Either[Throwable, A] -- narrow it here
  .left.map(DomainError.from)   // ... to an error type you actually own

The mechanism is not subtle once you look for it: Success#flatMap does not simply call f(value), it calls it inside a try that converts a NonFatal throw into Failure. So whenever f throws rather than returning Failure, the two sides of unit(a).flatMap(f) == f(a) differ: one is a value, the other is a stack unwinding past you. The same catch sits inside map. You can construct the counterexample in a REPL in ten seconds, which is more than can be said for most claims about monad laws.

The exclusion of fatal errors is deliberate and worth knowing. scala.util.control.NonFatal lets VirtualMachineError (including OutOfMemoryError and StackOverflowError), ThreadDeath, InterruptedException, LinkageError and Scala control-flow exceptions through untouched, so Try never swallows a condition the process cannot recover from. In practice the right lifetime for a Try is short: catch at the boundary, call toEither, map the Throwable into your own error type, and compose from there with a lawful instance.

Advertisement

List — the branching instance, and why order is fixed

List is the instance that makes people realise the abstraction is not about failure. Its flatMap is concatenate-map: each element produces a list, and the results are appended. A two-generator for-comprehension over lists is a nested loop, and the value it yields is every combination.

for
  a <- List(1, 2)
  b <- List(10, 20)
yield a + b            // List(11, 21, 12, 22) -- leftmost generator varies slowest

// desugars to
List(1, 2).flatMap(a => List(10, 20).map(b => a + b))

List(3).flatMap(f) == f(3)          // left identity: concat of a single list
xs.flatMap(List(_)) == xs           // right identity
// associativity is the associativity of list concatenation

for
  a <- List(1, 2, 3)
  if a % 2 == 1      // withFilter, so no intermediate List is materialised
  b <- List(a, -a)
yield b              // List(1, -1, 3, -3)

All three laws hold. List(a).flatMap(f) concatenates exactly one list and gives f(a); xs.flatMap(List(_)) rebuilds xs; and associativity is inherited from concatenation being associative. The short-circuit here is Nil, but it behaves quite unlike None: an empty result kills that branch, not the whole computation, so the other elements keep going.

Order is fixed by the desugaring rather than by the laws, and the distinction trips people up. Associativity licenses regrouping the binds; it says nothing about swapping two independent generators. Exchange the two lines above and you get List(11, 12, 21, 22) -- the same multiset, a different sequence -- because the leftmost generator is always the outer loop. If downstream code sorts or reduces commutatively that is harmless; if it takes the first result, it is a behaviour change. Note also that the strictness is the collection's: List materialises every intermediate, while LazyList and view do not, which is a performance property rather than a monadic one. See Scala collections and collections performance.

Future — eager, memoized, and not lawful in practice

Future[A] supports the whole vocabulary and is the reason a great deal of Scala async code reads well. It is also the standard counterexample, and the reason is not an exotic edge case -- it is the first thing Future does.

import scala.concurrent.{Future, ExecutionContext}
import ExecutionContext.Implicits.global

// The signature is not even the bare monadic shape:
//   def flatMap[S](f: T => Future[S])(implicit ec: ExecutionContext): Future[S]

val fa = Future { println("ran"); 1 }
for
  a <- fa
  b <- fa
yield a + b            // prints "ran" ONCE

for
  a <- Future { println("ran"); 1 }
  b <- Future { println("ran"); 1 }
yield a + b            // prints "ran" TWICE

// Inlining a val changed the program. The laws are equations you are meant to
// substitute into; if substitution is unsound, satisfying them proves nothing.

Start with the signature: flatMap takes a second, implicit ExecutionContext parameter, so Future does not even have the bare shape the abstraction asks for. That is a nuisance rather than a defect. The defect is eagerness. Future { body } submits body to the execution context at the moment of construction, so building the value is the effect. Bind the same val twice and the work happens once; write the expression out twice and it happens twice. Replacing a name with its definition changed the program, which is the definition of losing referential transparency.

This is why the honest statement is narrower than the slogan. With Future.successful as unit and side-effect-free functions, the three equations hold up to eventually-the-same-value, and Cats will give you a Monad[Future] if you have an ExecutionContext in scope -- with a documented caveat that it is lawful only for futures without side effects. Since running effects is the entire purpose of a Future, the laws stop carrying weight exactly where you would want to lean on them. The practical fallout is familiar to anyone who has operated a Future-based service: you cannot retry a Future, only the function that produced one; you cannot cancel it; and val versus def becomes semantically significant. Futures and ExecutionContext covers the operational side, and Cats Effect and ZIO exist largely to close this gap by making the effect a description that runs only when interpreted.

Monad transformers, and why OptionT exists

Monads do not compose. Given monads F and G there is no general recipe that makes F[G[_]] a monad -- you would need a distributive law turning G[F[A]] into F[G[A]], and no such law exists for arbitrary pairs. Every useful combination therefore has to be glued by hand, and a transformer is that gluing packaged as a type.

def findUser(id: Long): Future[Option[User]]
def findOrders(u: User): Future[Option[List[Order]]]

// Without a transformer the for-comprehension binds Option[User], not User:
findUser(id).flatMap {
  case None    => Future.successful(None)
  case Some(u) => findOrders(u)
}

// With one, the two short-circuits become one:
import cats.data.OptionT

def orders(id: Long): OptionT[Future, List[Order]] =
  for
    u <- OptionT(findUser(id))
    o <- OptionT(findOrders(u))
  yield o

orders(id).value      // back to Future[Option[List[Order]]]

The pain is concrete long before the theory is. A repository layer that returns Future[Option[User]] forces every caller into two levels: the for-comprehension binds over Future, so what you get is an Option[User], and you match on it to decide whether to launch the next Future. Three such steps and the function is a staircase. OptionT[Future, A] is a thin wrapper over Future[Option[A]] whose flatMap short-circuits on both layers at once, giving you back a single flat comprehension and a .value call at the end to unwrap. EitherT[F, E, A] does the same for F[Either[E, A]].

They are not free. Each step allocates a wrapper; inference gets harder because the transformer must be partially applied to be used as an F[_] ([X] =>> OptionT[Future, X] in Scala 3, a compiler plugin in Scala 2); and stacks more than two deep degrade both performance and error messages sharply. That cost is the direct motivation for effect types that build the extra channels in: ZIO[R, E, A] has a typed error channel precisely so that an EitherT over a task is never needed. The higher-kinded machinery involved is developed in the type system article.

Where the abstraction lives: Cats and ZIO

Everything so far has been about individual types. The thing you cannot do with the standard library is write a function that works for any monad, because there is no name to constrain on. That function is the whole point of the abstraction, and supplying it is what Cats is for.

import cats.Monad
import cats.syntax.all.*

// Impossible with the standard library alone -- there is no Monad to bound on.
def twice[F[_]: Monad, A](fa: F[A])(combine: (A, A) => A): F[A] =
  for
    x <- fa
    y <- fa
  yield combine(x, y)

twice(Option(3))(_ + _)   // Some(6)
twice(List(1, 2))(_ + _)  // List(2, 3, 3, 4)

// Writing your own instance means three methods, not two:
//   def pure[A](a: A): F[A]
//   def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
//   def tailRecM[A, B](a: A)(f: A => F[Either[A, B]]): F[B]   // stack safety

// And the laws are testable, not aspirational:
checkAll("Monad[Option]", MonadTests[Option].monad[Int, Int, String])

Cats defines Monad[F[_]] as a type class -- a trait parameterised by a type constructor, resolved implicitly, with instances for the standard library types provided by the library rather than by the types themselves. It sits above FlatMap and Applicative in a hierarchy, so a function that only needs map can ask for Functor and stay usable by more callers. The mechanics of implicit resolution that make [F[_]: Monad] work are in Scala implicits and Scala 3 contextual abstraction.

Two details separate people who use the type class from people who define instances of it. The first is tailRecM: alongside pure and flatMap you must supply a stack-safe fixed point, turning A => F[Either[A, B]] into F[B], so that generic recursive combinators do not overflow on monads whose flatMap is not itself trampolined. The second is that the laws are executable: cats-laws plus discipline give a ScalaCheck suite you run against your instance, so "is it lawful" becomes a test result rather than an opinion. ZIO takes the other route -- ZIO[R, E, A] is a monad in A and ships its own combinator vocabulary rather than requiring the type class, with zio-prelude available for those who want the hierarchy. Writing an entire program against F[_]: Monad instead of a concrete effect is the tagless-final style, developed in tagless final.

Where flatMap stops being the right tool

The abstraction has a shape, and the shape has costs. Knowing where it stops paying is more useful than another instance.

// flatMap forces sequence: f(a) cannot begin before a exists.
for
  a <- callServiceA()   // these two are independent ...
  b <- callServiceB()   // ... and this still runs them one after the other
yield a + b

// Independent work wants Applicative, not Monad:
(callServiceA(), callServiceB()).parMapN(_ + _)     // Cats
callServiceA().zipPar(callServiceB())               // ZIO

// And some useful shapes are Applicative on purpose *because* they are not monads:
Validated.invalidNel("bad email").product(Validated.invalidNel("bad age"))
// accumulates BOTH errors -- flatMap could not, having nothing to feed the next step

Sequencing is mandatory, not optional. The signature of flatMap says the second computation is produced by a function of the first result, so it cannot start until that result exists. A for-comprehension over two independent effects therefore serialises them even though nothing required it. Independent work wants Applicative, which combines effects without letting one choose the other: parMapN in Cats, zipPar in ZIO, Future.sequence over futures that are already running. Future's eagerness accidentally parallelises here, which is why naively translated IO code sometimes looks slower than the Future version it replaced.

Some structures are deliberately not monads. Cats Validated accumulates every error rather than stopping at the first, and that is only possible because it is Applicative and not Monad -- a lawful flatMap would have to stop at the first failure, having no value to feed the continuation. Reaching for Either when you wanted all the validation errors is a common and avoidable mistake. Finally, when you want to inspect or reinterpret a program rather than just run it, the move is to reify flatMap itself as data -- the free monad construction, a genuinely different technique covered in free monads, with tagless final as its usual alternative.

Scala has no Monad trait: for-comprehensions desugar to flatMap, map and withFilter by name, before typing, and check nothing. Option, Either and List are lawful and differ only in what they short-circuit on. Try breaks left identity because its flatMap catches, and Future breaks referential transparency because construction runs the work. Reach for Cats or ZIO when you need the abstraction itself, and for Applicative when the steps are independent.