Skip to content
← All insights
Functional Scala15 min read

Category theory in practical Scala: useful vocabulary, not the goal

How category-theoretic ideas influence map, flatMap, typeclasses and effect libraries—and how to use that vocabulary without turning application code into an abstraction exercise.

Begin with objects, arrows and composition

Category theory is a branch of mathematics concerned less with what individual things contain and more with how they relate and compose. A category has objects and arrows between those objects. Every object has an identity arrow, and compatible arrows can be composed to form another arrow.

Imagine three objects A, B and C. An arrow f takes us from A to B, and g takes us from B to C. Composition gives g ∘ f, a route from A directly to C. The important promise is that composition follows consistent laws, so a larger route can be assembled from smaller ones without its meaning depending on arbitrary grouping.

The names can sound remote from application development, but the basic instinct is familiar: define small transformations with compatible boundaries, then combine them predictably. The diagram introduces the mathematical vocabulary before we translate it—carefully—into Scala.

Composition as a triangle

Following f and then g gives the same route as g ∘ f.

A triangle of composable functionsObject A sits above objects B and C. Function f runs from A to B, function g runs from B to C, and the composed function g after f runs directly from A to C, forming a triangle.ABCfgg ∘ f
ObjectsA, B and C
Arrowsf and g describe relationships
Compositiong ∘ f is the direct A-to-C route

The influence on Scala is real, but usually indirect

Functional programming borrows category-theoretic vocabulary because programs repeatedly face the same question: how can small transformations be combined without losing the structure around their values?

Scala does not require category theory to write an API, model a domain or maintain a Play service. Its type system does, however, make abstractions such as higher-kinded types and typeclasses expressible. Libraries such as Cats use those features to provide lawful interfaces for composition.

Most application code encounters the results rather than the mathematics. Calling map on Option, sequencing Either with flatMap or combining independent validations can be useful without describing categories or proving theorems. The theory explains why the operations fit together; it need not dominate the application vocabulary.

The practical surface is familiar Scalascala 2.13
val normalised: Option[String] =
  submittedName.map(_.trim).filter(_.nonEmpty)

val account: Either[OpenError, Account] = for {
  name    <- AccountName.from(rawName)
  balance <- OpeningBalance.from(rawBalance)
  account <- Account.open(name, balance)
} yield account

// Application code can use composition without discussing
// category-theoretic terminology at each call site.

Types and functions provide an analogy, not a perfect model

A common introduction treats types as objects and functions as morphisms between them. That is a useful intuition because functions compose and identity functions leave values unchanged. It is still a simplified model of real Scala.

Scala functions can throw, fail to terminate, mutate state or observe the outside world. Subtyping, null and JVM runtime behaviour add details that the clean mathematical model does not capture automatically. Effect types and disciplined functional design make more of that behaviour explicit, but the implementation remains a program running on a real platform.

Use the analogy to reason about composition, not to claim that any Scala function is automatically a pure mathematical arrow. The value comes from identifying which assumptions make a refactoring safe.

Pure composition has a predictable shapescala 2.13
val trim: String => String = _.trim
val lower: String => String = _.toLowerCase
val normalise: String => String = trim.andThen(lower)

normalise("  ADA  ") // "ada"

// If either function also writes a database or reads the clock,
// the same type no longer tells the complete behavioural story.

Functor explains the recurring shape of map

A Functor provides map for a type constructor F[_]. Given F[A] and A => B, map produces F[B] without discarding the surrounding structure. For Option the structure is possible absence; for List it is multiplicity; for Future it is asynchronous completion.

A lawful map preserves identity and composition. Mapping the identity function should not change the value, and mapping two pure functions in succession should agree with mapping their composition. These laws make helper extraction and pipeline refactoring predictable.

The shared abstraction does not make the contexts operationally equivalent. List.map visits elements, Future.map schedules a callback through an ExecutionContext and IO.map extends a description of work. Generic vocabulary removes repeated structure, not the need to understand the concrete type.

Functor laws protect ordinary refactoringscala / pseudocode
// Identity
fa.map(identity) == fa

// Composition
fa.map(f).map(g) == fa.map(f.andThen(g))

val displayNames: Option[String] =
  account.map(_.name).map(_.trim)

val sameShape: Option[String] =
  account.map(value => value.name.trim)

Monad describes dependent composition

Monad adds the ability to place a value in a context and sequence a function that returns the same context. In Scala this appears through pure and flatMap. A for-comprehension is the application-facing syntax normally used for the sequence.

Option stops at None, Either propagates Left and Future continues after successful asynchronous completion. The common interface is valuable when a reusable function truly needs only dependent sequencing.

The monad laws—left identity, right identity and associativity—constrain how that sequencing behaves. They matter because extracting a helper or regrouping flatMap calls should not silently change a program. They are engineering guarantees for composition, not decorations added to make code sound mathematical.

A law supports helper extractionscala / pseudocode
// Associativity
fa.flatMap(f).flatMap(g) ==
  fa.flatMap(a => f(a).flatMap(g))

// That equivalence allows a sequence to be regrouped around
// a named workflow without changing its declared meaning.

Other abstractions solve more specific problems

Applicative composition combines independent contextual values. In Cats, mapN can build a result from independent validations or effects without pretending the second value depends on the first. Traverse applies an effectful function across a collection and turns List[F[A]] into F[List[A]] with defined sequencing behaviour.

Semigroup and Monoid describe ways to combine values, with Monoid also supplying an identity element. They belong to the broader algebraic vocabulary used by functional libraries and support operations such as combining validation errors, totals or configuration fragments.

Names are useful when they identify a capability already present in the problem. They become noise when a team must learn several abstractions to understand a calculation that could have remained a small domain function.

Use the operation that matches the dependencyscala 2.13 / Cats
import cats.syntax.all._

val name: Either[List[String], AccountName] = validateName(rawName)
val email: Either[List[String], Email] = validateEmail(rawEmail)

// The validations are independent, so applicative composition fits.
val request: Either[List[String], Registration] =
  (name, email).mapN(Registration.apply)

// Dependent steps would instead require flatMap.

Typeclasses bring the vocabulary into Scala

Scala encodes many of these capabilities as typeclasses: parameterised traits with instances for concrete types. Implicit parameters in Scala 2 and given or using in Scala 3 allow generic code to request evidence such as Monad[F] or Semigroup[A].

This supports reusable libraries and tagless-final programs. A workflow can state that it requires sequencing without selecting IO or Future in the same module, while an interpreter supplies the concrete effect at the application boundary.

The abstraction has a cost. F[_], contextual evidence and syntax imports add concepts to signatures and compiler errors. Use them when multiple interpreters, reusable effect-polymorphic logic or a genuine capability boundary repays that cost—not because abstract code is presumed to be more functional.

A generic signature should earn its generalityscala 2.13 / Cats
def loadAndScore[F[_]: Monad](
  load: AccountId => F[Account],
  score: Account => F[Score],
  id: AccountId
): F[Assessment] =
  for {
    account <- load(id)
    value   <- score(account)
  } yield Assessment(account, value)

// If every caller uses Future and no reuse is expected, a concrete
// Future signature may communicate the application more honestly.

Domain language should remain in charge

An account system should primarily talk about accounts, balances and decisions. Functor and Monad describe how computations compose; they do not identify the business rules, valid states or useful service boundaries.

A technically lawful abstraction can still be badly placed. A generic repository algebra may leak storage concepts, an error hierarchy may be too broad, and a tagless-final service may obscure a simple operation behind type parameters. Category theory cannot decide those application responsibilities.

Keep mathematical vocabulary near infrastructure, library and composition concerns where it adds precision. Keep domain APIs expressed through meaningful case classes, sealed traits and operations. The two levels can work together without competing for every name in the codebase.

Keep the business decision concretescala 2.13
sealed trait WithdrawError
object WithdrawError {
  case object AccountClosed extends WithdrawError
  final case class InsufficientFunds(available: BigDecimal)
      extends WithdrawError
}

def withdraw(
  account: Account,
  amount: PositiveAmount
): Either[WithdrawError, Account] = ???

// Either supplies composition; the domain types explain the decision.

Be cautious without dismissing the theory

Avoiding category theory entirely can make library design and functional Scala APIs seem arbitrary. Learning the basic relationships between map, flatMap, applicative composition and laws helps engineers predict behaviour and read Typelevel code with less guesswork.

The opposite mistake is treating abstraction as the outcome. Warning signs include generalising before a second use exists, introducing F[_] throughout a small concrete service, using mathematical names for domain operations and accepting difficult inference as proof of sophistication.

A practical standard is simple: use the abstraction when it removes real duplication, states a capability accurately or protects refactoring through laws. Prefer concrete code when it makes execution, failure and ownership easier to see. The strongest Scala code is not the most abstract code; it is the code whose abstractions match the problem.

  • Does this abstraction describe a repeated composition problem?
  • Which law or capability makes the code safer to change?
  • Do callers genuinely benefit from effect polymorphism?
  • Are execution and failure semantics still visible?
  • Does domain vocabulary remain clearer than library vocabulary?
  • Would Option, Either, Future or IO directly be easier to own?