Cats is the Typelevel library of functional abstractions for Scala: type classes such as Monoid and Traverse, data types such as NonEmptyList and Eval, and the laws that keep them honest. It is the foundation under Cats Effect, fs2, http4s, circe and doobie, so most Scala teams depend on it whether they chose it or not. The problem is orientation. The library is large, its names come from category theory, and tutorials tend to start at Functor and stop at Monad.
This article is the map instead: what is in each module, how imports work today, the kernel classes you will use every week, the data types worth knowing and when to reach for each, how to test your own instances against the laws, and a worked aggregation that shows why lawful instances pay off. Functor and Applicative in depth, error handling and the monad laws each have their own articles, linked where they fit.
What is in the box: modules and compatibility
Cats ships as several artifacts. cats-kernel holds the small set of type classes with no higher kinds (Semigroup, Monoid, Eq, PartialOrder, Order, Hash and their commutative and band variants) and has no dependencies. cats-core adds the higher-kinded hierarchy (Functor, Apply, Applicative, FlatMap, Monad, Foldable, Traverse, MonadError and more), Show, the data types and all instances for the standard library. cats-laws and cats-kernel-laws encode the laws as properties; cats-free provides Free; alleycats-core collects useful instances that cannot satisfy the laws.
// build.sbt -- check Scaladex for the current 2.x patch release
val catsVersion = "2.13.0"
val disciplineMunitVersion = "2.0.0"
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-core" % catsVersion,
"org.typelevel" %% "cats-laws" % catsVersion % Test,
"org.typelevel" %% "discipline-munit" % disciplineMunitVersion % Test
)Cats 2.x keeps binary compatibility across minor releases, checked with MiMa in the build. That matters because nearly every Typelevel library depends on cats-core: you can bump Cats to the newest 2.x without waiting for http4s or circe to republish. Evicting to a newer minor version is safe; downgrading below what a library was built against is not.
Imports today: syntax, not instances
Since Cats 2.2, instances for standard types live in the implicit scope of the type class companions, so the compiler finds Monoid[Int] or Traverse[List] without any import. What you still import is syntax, the extension methods such as |+|, .traverse and .show:
import cats.syntax.all.* // Scala 3 (use ._ on Scala 2)
val total = List(3, 4, 5).combineAll // 12, needs Monoid[Int]
val merged = Map("a" -> 1) |+| Map("a" -> 2, "b" -> 3) // Map(a -> 3, b -> 3)
val same = 1 === 1 // type-safe equality via Eq
val opt = 42.some // Option[Int], not Some[Int]
val err = "boom".asLeft[Int] // Either[String, Int]Older code uses import cats.implicits._, which imports syntax and instances together. It still works but is no longer needed, and mixing it with cats.syntax.all in the same file causes ambiguous-implicit errors. Pick cats.syntax.all in new code. On Scala 3, your own instances are written with given, and simple ones can use derives for Eq or Show, or Kittens for richer derivation (see Kittens).
The kernel: Semigroup, Monoid, Eq, Order and Show
Most day-to-day value comes from the kernel, not from monads. A Semigroup[A] has one method, combine(x, y), which must be associative: (x |+| y) |+| z == x |+| (y |+| z). A Monoid adds an empty that changes nothing when combined. Those two laws are what let a framework split work into chunks, combine each chunk in parallel and merge the results in any grouping. Instances compose: if V has a semigroup then so do Map[K, V] (merge by key), Option[V] and tuples of semigroups.
Eq replaces universal == with a type-checked ===, so userId === orderId fails to compile when the types differ. Order gives total ordering, built with Order.by(_.createdAt) and combined with whenEqual for tie-breakers; toOrdering bridges to the standard library. Show gives a deliberate text form, which keeps secrets out of logs because a type without a Show instance cannot be interpolated with show"...".
import cats.{Order, Show}
import cats.syntax.all.*
final case class Invoice(id: String, dueDate: java.time.LocalDate, amount: BigDecimal)
given Order[Invoice] =
Order.whenEqual(
Order.by[Invoice, Long](_.dueDate.toEpochDay), // due date first
Order.by[Invoice, String](_.id)) // then id
given Show[Invoice] = Show.show(inv => s"Invoice(${inv.id}, due ${inv.dueDate})") // no amount
val sorted = invoices.sorted(Order[Invoice].toOrdering)
val oldest = invoices.minimumOption // uses Order, not Ordering
log.info(show"next due: ${sorted.head}")Each instance is a single, named decision about the type. When the business changes the tie-breaking rule, there is one place to edit, and every sort, max and distinct that relies on it follows.
Data types worth knowing
| Type | What it is | Reach for it when |
|---|---|---|
NonEmptyList[A] | a list proven to have a head | an empty collection would be a bug: recipients, error lists |
Chain[A], NonEmptyChain | constant-time append and prepend; fast concatenation | accumulating many small pieces, e.g. errors in ValidatedNec |
Ior[A, B] | Left, Right, or Both | a result can succeed with warnings: parsing with deprecations |
Eval[A] | Now, Later (memoised) or Always; stack-safe flatMap | deep recursion and lazy folds such as foldRight |
Kleisli[F, A, B] | a wrapped A => F[B]; also ReaderT | composing effectful functions; http4s routes are built on it |
State[S, A] | S => (S, A) built on Eval | pure simulations and interpreters with threaded state |
EitherT, OptionT | transformers stacking Either/Option inside F | short-circuiting inside an effect; covered with the monads |
Validated, traverse and Parallel belong here too, but they are the subject of the Functor and Applicative article; EitherT and the laws behind flatMap are in Monads in Scala; choosing between Either, Validated and Ior for errors is in functional error handling.
Two of these types earn a closer look because they solve problems the standard library cannot. Ior models a result that can carry warnings alongside a value. A configuration loader that accepts a deprecated key should still succeed but tell the operator; Either forces you to choose between the value and the message, while Ior keeps both and accumulates warnings through flatMap as long as the left side has a semigroup:
import cats.data.{Ior, NonEmptyChain as Nec}
import cats.syntax.all.*
type Loaded[A] = Ior[Nec[String], A]
def port(cfg: Map[String, String]): Loaded[Int] =
(cfg.get("port"), cfg.get("http.port")) match
case (Some(p), _) => Ior.right(p.toInt)
case (None, Some(p)) => Ior.both(Nec.one("http.port is deprecated; use port"), p.toInt)
case (None, None) => Ior.left(Nec.one("port is required"))
def host(cfg: Map[String, String]): Loaded[String] =
cfg.get("host") match
case Some(h) => Ior.right(h)
case None => Ior.left(Nec.one("host is required"))
val server = for { p <- port(cfg); h <- host(cfg) } yield s"$h:$p"
// Both(warnings, "db1:8080") when only the old key is setEval solves a different problem: stack depth. A naive recursive fold over a million-element structure overflows the JVM stack. Wrapping each step in Eval.defer turns the recursion into a chain of heap objects that value runs in a loop, which is how Cats implements foldRight safely for every Foldable. If you write a recursive interpreter or tree walk by hand, return Eval from the recursive function and call .value once at the top.
Worked example: mergeable request statistics
Suppose a batch job computes request statistics per endpoint across hundreds of log files, processed in parallel. You want count, error count, minimum and maximum latency. Model the per-endpoint summary as a value with a lawful monoid, then everything else is foldMap:
import cats.kernel.CommutativeMonoid
import cats.syntax.all.*
final case class Stats(count: Long, errors: Long, minMs: Long, maxMs: Long)
object Stats:
def one(latencyMs: Long, isError: Boolean): Stats =
Stats(1, if isError then 1 else 0, latencyMs, latencyMs)
given CommutativeMonoid[Stats] with
val empty = Stats(0, 0, Long.MaxValue, Long.MinValue)
def combine(a: Stats, b: Stats) =
Stats(a.count + b.count, a.errors + b.errors,
a.minMs min b.minMs, a.maxMs max b.maxMs)
final case class Line(endpoint: String, latencyMs: Long, status: Int)
def summarise(lines: List[Line]): Map[String, Stats] =
lines.foldMap(l => Map(l.endpoint -> Stats.one(l.latencyMs, l.status >= 500)))
// per-file results computed anywhere, merged in any order and grouping:
val total: Map[String, Stats] = perFileResults.combineAllThree things fell out for free. Map[String, Stats] is a monoid because Stats is, so merging maps by key needs no code. combineAll over per-file results is correct regardless of how files were partitioned, because combine is associative. And because it is also commutative, results can be merged in completion order, which is what parallel and streaming frameworks do. The empty uses Long.MaxValue and Long.MinValue as identities for min and max; a reader should check that empty |+| x == x, and the next section makes the compiler's test suite check it.
If you want an average, store the sum, not the mean. Averages do not combine associatively; sums and counts do. Percentiles need a mergeable sketch (t-digest or HDR histogram) with its own semigroup. Designing summaries so they combine is the habit Cats teaches more than any monad.
Testing your instances against the laws
A hand-written instance that breaks a law gives wrong answers only under certain groupings, which is the worst kind of bug. cats-laws packages each type class's laws as ScalaCheck properties, and discipline runs them in your test framework:
import cats.kernel.{CommutativeMonoid, Eq}
import cats.kernel.laws.discipline.CommutativeMonoidTests
import cats.syntax.all.*
import munit.DisciplineSuite
import org.scalacheck.{Arbitrary, Gen}
class StatsLawsSuite extends DisciplineSuite:
// listOf can be empty, so the identity value is generated too
given Arbitrary[Stats] = Arbitrary(
Gen.listOf(Gen.zip(Gen.choose(0L, 60000L), Gen.oneOf(true, false)))
.map(_.foldMap((ms, err) => Stats.one(ms, err)))
)
given Eq[Stats] = Eq.fromUniversalEquals
checkAll("CommutativeMonoid[Stats]", CommutativeMonoidTests[Stats].commutativeMonoid)The suite generates random values and checks associativity, identity and commutativity, plus derived properties such as combineAll agreeing with a fold. Write a generator that covers the edges, including the empty value; a generator that never produces edge cases proves little. The same pattern tests Functor, Traverse or Monad instances for your own data types.
Failure modes and trade-offs
- Ambiguous implicits. Mixing
cats.implicits._withcats.syntax.all._, or importing per-type syntax twice. Use the singlesyntax.allimport. - Lawless instances. A
Monoidwhoseemptyis not an identity, or a floating-point sum treated as associative. Run the law suites; document approximate instances or move them to alleycats. - Accidental strictness.
foldRighton a huge structure withoutEval, or deep recursion in a customMonadwithout a stack-safetailRecM. The law suite includes a stack-safety check fortailRecM. - Abstraction for its own sake.
Kleisliand transformer stacks that a direct function or a single effect type would express more clearly. Ask for the weakest type class a function needs, and stop there. - Version skew. A library compiled against a newer Cats minor version than you resolve gives linkage errors. Let the build evict to the highest 2.x.
The trade-off is vocabulary against reuse. A team fluent in Monoid, traverse and NonEmptyList writes less code and gets correctness properties for free; a team that is not reads cryptic operators. Start with the kernel and a few data types, which deliver most of the value, and adopt Cats Effect when you need concurrency and resource safety.
What to do next
- Replace any
cats.implicits._imports withcats.syntax.all.*and remove now-redundant instance imports. - Find one aggregation in your code (metrics, counters, report totals) and model it as a lawful
MonoidwithfoldMap. - Add
cats-lawsand discipline to tests and run law checks for every hand-written instance. - Swap
ListforNonEmptyListwherever empty would be a bug, and useEq/===for domain IDs. - Give sensitive types no
Showinstance, and log withshow"...". - Read the Functor/Applicative and error-handling articles before reaching for transformers.