Option, Either and other monads in practical Scala
How Option, Either, collections, Future and IO represent different computational contexts—and how map, flatMap and for-comprehensions compose them.
The abstraction is about sequencing in a context
A type such as Option[A], Either[E, A], Future[A] or IO[A] contains or describes an A with some additional computational context. The context may represent absence, an expected error, asynchronous completion or an effectful program.
A monad provides an operation that places a plain value into that context—commonly called pure—and flatMap, which sequences a contextual value into the next contextual computation. In simplified form, flatMap has the shape F[A] => (A => F[B]) => F[B].
The abstraction lets code describe dependent steps without manually unpacking and rebuilding the context at every line. It does not make all contexts behave alike. Option can stop with None, Either with Left, Future can fail asynchronously, and IO describes work for a runtime to execute.
trait Monad[F[_]] {
def pure[A](value: A): F[A]
def flatMap[A, B](value: F[A])(next: A => F[B]): F[B]
}
// map can be derived from pure and flatMap
def map[A, B](value: F[A])(f: A => B): F[B] =
flatMap(value)(a => pure(f(a)))Option sequences computations that may be absent
For Option, pure creates Some(value). flatMap applies the next function to a Some and propagates None without calling it. A chain therefore stops at the first absent value.
This is useful when absence itself is sufficient information. If callers need to know whether input was malformed, data was missing or access was denied, Either with a specific error type carries a more useful context.
The for-comprehension is syntax for the same map and flatMap calls. It improves readability but does not introduce a different execution model.
def primaryAddress(user: User): Option[Address] =
for {
addressId <- user.primaryAddressId
address <- addresses.get(addressId)
} yield address
// Equivalent shape:
user.primaryAddressId.flatMap { addressId =>
addresses.get(addressId).map(address => address)
}Either preserves a typed failure
For Either[E, *], the context includes a fixed error type E. flatMap runs the next step for Right and propagates Left. This makes it suitable for fail-fast validation and domain workflows in which later steps depend on earlier results.
The error type must remain compatible throughout the chain. Translating a parsing error and a domain error into one operation-specific algebra is often clearer than widening everything to Exception or String.
This also shows why a monad is a relationship over a type constructor, not a label attached to a class in isolation. Either becomes a one-parameter context only after its left-hand error type is fixed.
type Result[A] = Either[CreateOrderError, A]
def createOrder(input: CreateOrderRequest): Result[Order] =
for {
accountId <- parseAccountId(input.accountId)
amount <- parseAmount(input.amount)
order <- Order.create(accountId, amount)
} yield orderFuture and IO obey similar interfaces with different timing
Creating a Future normally schedules its body immediately on an ExecutionContext. A Cats Effect IO value describes a computation and does not run it merely because it was constructed. Both support map and flatMap, but substituting one for the other changes execution, cancellation and resource semantics.
Future memoises its eventual result: attaching several transformations to the same Future observes the same completed computation. Running the same IO value more than once interprets its description more than once unless the program explicitly memoises it.
Generic syntax can hide these differences, so engineers still need to understand the concrete effect. A typeclass removes repeated sequencing code; it does not remove operational behaviour such as thread usage, blocking work, cancellation or resource lifetime.
val future: Future[UUID] = Future(UUID.randomUUID())
val firstFuture = future
val secondFuture = future // observes the same scheduled computation
val io: IO[UUID] = IO(UUID.randomUUID())
val program = for {
first <- io
second <- io // evaluates the description again
} yield (first, second)Collections and Try add different meanings again
List is also a monad. Its context is multiplicity: a step can produce zero, one or many results, and flatMap concatenates those results. A for-comprehension over two lists therefore produces combinations; it does not fail fast like Either or schedule work like Future.
Try captures a thrown non-fatal exception as Success or Failure. It is useful around exception-based APIs, but Throwable is usually too broad for an application contract. Convert to a meaningful error type when the code crosses from an implementation detail into domain or application behaviour.
Sharing map and flatMap does not make these types interchangeable. The outer type communicates what kind of program is being composed, and changing it changes the observable meaning of the code.
val combinations: List[String] =
List("small", "large").flatMap { size =>
List("sage", "plum").map(colour => s"$size-$colour")
}
val port: Try[Int] = Try(configuration.getString("port").toInt)
val validatedPort: Either[ConfigurationError, Int] =
port.toEither.left.map(error =>
ConfigurationError.InvalidPort(error.getMessage)
)Nested contexts preserve more than one concern
Real services often need more than one context. Future[Either[AccountError, Account]] distinguishes asynchronous operational failure from an expected account result. Neither layer is redundant, so a single flatMap cannot remove both.
The direct version is often clearest for a short workflow: flatMap the Future, then map or flatMap the Either inside it. When this pattern dominates a larger module, Cats EitherT can provide one compositional interface while retaining both layers.
A transformer reduces nesting syntax; it does not remove the need to decide which errors belong in Left and which failures belong to the outer effect. Introduce it when repeated composition is the problem, not merely because a nested type looks untidy.
import cats.data.EitherT
import scala.concurrent.Future
type AsyncResult[A] = EitherT[Future, AccountError, A]
def load(id: AccountId): AsyncResult[Account] =
EitherT(repository.find(id).map {
case Some(account) => Right(account)
case None => Left(AccountError.NotFound(id))
})
def rename(id: AccountId, name: AccountName): AsyncResult[Account] =
for {
account <- load(id)
renamed <- EitherT.fromEither[Future](account.rename(name))
_ <- EitherT.liftF(repository.save(renamed))
} yield renamedThe laws protect refactoring
Monad instances are expected to satisfy left identity, right identity and associativity. Left identity says that placing a value in the context and immediately flatMapping a function is equivalent to calling the function. Right identity says flatMapping pure does not change the computation. Associativity says regrouping dependent flatMap calls does not change the result.
These are behavioural equivalences, not necessarily identical implementation steps. They matter because for-comprehensions, helper functions and library combinators routinely regroup expressions. Without the laws, apparently harmless refactoring could alter meaning.
Side effects performed while constructing values can break reasoning even when the underlying data type has a lawful instance. Keep effects suspended in the effect type or controlled at explicit boundaries rather than hiding them inside functions presented as pure.
// Left identity
pure(a).flatMap(f) == f(a)
// Right identity
fa.flatMap(pure) == fa
// Associativity
fa.flatMap(f).flatMap(g) ==
fa.flatMap(a => f(a).flatMap(g))Generic code is useful when the capability is genuinely generic
Cats exposes Monad[F] as a typeclass, allowing a function to sequence any F with a lawful instance. This is valuable for reusable libraries and application modules whose logic genuinely depends only on sequencing.
Application code does not need to abstract over every concrete effect. A service written directly in Future or IO is often easier for its team to understand, test and operate. Introducing F[_] also introduces typeclass constraints, syntax imports and decisions about which effects are permitted.
Start with the concrete program. Abstract when two real interpreters, reusable logic or a testable capability boundary justify it. Monad is a precise and useful vocabulary for composition; it is not an architectural objective by itself.
import cats.Monad
import cats.syntax.flatMap._
import cats.syntax.functor._
def enrich[F[_]: Monad](
load: AccountId => F[Account],
score: Account => F[Score],
id: AccountId
): F[ScoredAccount] =
for {
account <- load(id)
value <- score(account)
} yield ScoredAccount(account, value)