A practical introduction to functional programming and Cats in Scala: pure functions, type classes, and validation with Either and ValidatedNel.
A practical introduction to functional programming and Cats in Scala: pure functions, type classes, and validation with Either and ValidatedNel.
Functional programming and Cats help make a Scala program’s decisions, failures, and composition easier to see. The useful starting point is a request handler, not an abstraction hierarchy.
Consider a signup endpoint. It trims a name, checks an email address, throws an exception for invalid input, writes a row, and logs the result. Each step is familiar. Put them in one method, however, and answering a simple question becomes awkward: what can I test without a database, and what happens when two fields are invalid?
I want the types and the structure of the code to answer those questions. Which inputs does a decision need? Can it fail? Should validation report one problem or all independent problems? Where does external interaction begin?
This is the first article in the Cats Effect & fs2 series. I will start with Cats Core and a few practical ideas behind functional programming. You need basic Scala familiarity with case classes, collections, Option, and Either. No category theory background is needed.
A pure function computes a result from its explicit inputs without reading or changing the outside world. Given the same inputs, it returns the same result and produces no observable side effects. Reading a clock, changing a shared variable, sending an email, and writing to a database are effects.
This small transformation is pure:
def normalizeName(raw: String): String = raw.trimScalaThe input is a Scala String. There is no configuration lookup or mutable counter hidden inside the method. Calling normalizeName(" Ada ") produces "Ada".
This leads to referential transparency: I can replace an expression with its value without changing the program’s observable behavior. Replacing that normalization call with "Ada" preserves the behavior. If the method also printed a message, the replacement would remove the output. It would no longer be the same program.
The practical benefit is local reasoning. I can understand this transformation without inspecting a database, a thread, or the sequence of earlier requests. Tests can pass inputs directly and compare outputs. The Scala guide to pure functions explains this distinction in more detail.
Immutability helps preserve that reasoning, but val alone does not make data immutable. A val holding a mutable buffer prevents reassignment of the reference; the buffer can still change. Prefer immutable values for domain transformations, and keep any necessary mutation local and controlled.
Another useful property is totality: producing a defined result for every input in the declared domain. A function returning Int but throwing for ordinary malformed input hides a possible outcome. Representing that expected failure with Either makes the contract clearer. This does not make the program immune to every runtime failure.
An application still has to read requests and persist data. A useful design separates those interactions from the decisions that can be expressed as pure transformations.
For the signup endpoint, the inner function accepts request values and returns either validation errors or a valid Signup. The outer handler decides how to turn errors into an HTTP response. Only the valid branch proceeds to persistence, which can have its own failures.
This is often called a functional core with an imperative shell. It is a design guideline, not a rule that every function must fit into two neat layers. The important boundary is visible responsibility: domain checks do not unexpectedly write rows, and the handler does not need to repeat those checks.
A Valid(Signup(...)) returned by signup means the input passed these local checks. The public case class can still be constructed directly, so its type alone does not enforce that invariant. Validation also does not prove that an email is deliverable, that an account is unique, or that a database write will succeed. Those facts need additional interactions and error handling.
Cats is a library of abstractions and data types for functional programming in Scala. This article uses Cats Core, the cats-core dependency. Its contribution is reusable vocabulary for behavior that appears across many types.
Scala already provides Option, Either, immutable collections, pattern matching, and functions as values. Modern Scala’s Either already has map and flatMap. You do not need Cats to write a pure function or an Either for-comprehension.
Cats adds things such as Eq for typed equality, Functor for a common mapping interface, and Validated for validation results that can accumulate independent errors. It also supplies instances and syntax that connect those ideas to familiar types.
The reason to introduce a library is a concrete need. Accumulating errors across several input fields is one. Reusing an operation across different contexts is another. Turning a short, clear method into a generic framework simply because Cats makes it possible is usually extra work.
A type class describes an operation for a type without requiring that type to inherit from it. An instance supplies the implementation for a particular type. Syntax makes the operation convenient to call.
Here is that distinction with a small account identifier:
import cats.Eq
import cats.syntax.all.*
final case class AccountId(value: String)
object AccountId:
given Eq[AccountId] = Eq.by(_.value)
AccountId("a-1") === AccountId("a-1") // true
AccountId("a-1") =!= AccountId("b-2") // trueScalaEq[AccountId] is the equality capability. The given is its instance, defined by comparing the underlying String values. The === operator is syntax for that capability. AccountId does not extend Eq.
The instance lives in the companion object so Scala can find it when equality for AccountId is needed. The syntax import makes === available. Type class, instance, and syntax solve different parts of the problem.
Compared with universal ==, Cats’ usual === usage checks operands against a shared type with an Eq instance. An accidental comparison such as AccountId("a-1") === "a-1" is rejected rather than silently returning a misleading result. Scala’s own strict-equality feature is another tool; this example is introducing the type class pattern.
Instances also have laws. An equality relation should be reflexive, symmetric, and transitive. The compiler checks that the required operations exist; it does not prove that their implementation obeys those laws. The Cats Eq documentation describes the contract.
Think of Option[String] as a string that might be absent and List[String] as zero or more strings. Both support transforming their strings with map, while preserving their own structure.
A Functor captures that shared operation:
import cats.Functor
import cats.syntax.all.*
def normalizeInside[F[_]: Functor](values: F[String]): F[String] =
values.map(normalizeName)
normalizeInside(Option(" Ada ")) // Some("Ada")
normalizeInside(List(" Ada ", " Lin ")) // List("Ada", "Lin")
normalizeInside(Option.empty[String]) // NoneScalaF[_] means a type constructor with one type parameter. Option becomes Option[String] when supplied with String; List behaves similarly. The context bound F[_]: Functor asks Scala for a Functor[F] instance.
The function knows how to normalize a string. The instance knows what mapping means for its context. For Option, None stays None. For List, mapping preserves the number and order of elements.
The Functor laws say that mapping the identity function changes nothing, and mapping two functions in sequence agrees with mapping their composition. These rules make generic code predictable.
I would use this generic version when code actually needs to support several contexts. For a single list, names.map(normalizeName) is already clear and sufficient.
Suppose a request contains a quantity as text. Checking whether the quantity is positive requires parsing it first. There is no integer to check when parsing fails.
enum QuantityError:
case NotInteger, NotPositive
def parseQuantity(raw: String): Either[QuantityError, Int] =
raw.toIntOption.toRight(QuantityError.NotInteger)
def requirePositive(value: Int): Either[QuantityError, Int] =
Either.cond(value > 0, value, QuantityError.NotPositive)
def quantity(raw: String): Either[QuantityError, Int] =
for
parsed <- parseQuantity(raw)
positive <- requirePositive(parsed)
yield positive
quantity("3") // Right(3)
quantity("three") // Left(NotInteger)
quantity("0") // Left(NotPositive)ScalaEither[QuantityError, Int] says that the result is an expected error or an integer. Left contains the error; Right contains the successful value. toIntOption handles malformed or out-of-range integer text without throwing a parsing exception.
The for-comprehension sequences the operations using flatMap and map. A Left from parsing prevents the positive check from running. That is appropriate here because the second step needs the first step’s value.
The distinction is in the function being supplied. map accepts a function returning an ordinary value. flatMap accepts a function returning another value in the context, such as another Either, and avoids an extra nested layer.
Cats names the sequencing capability FlatMap. A Monad provides flatMap together with pure, which places an ordinary value into the context. Here, the useful question is whether the next step needs an earlier successful result. The Monad documentation develops that idea beyond this example.
A name and an email can be checked independently. A blank name does not prevent checking the email. Returning one error at a time makes a user submit the same form repeatedly to discover all its problems.
For that situation, use ValidatedNel[SignupError, A]. It is an alias for Validated[NonEmptyList[SignupError], A]: either a valid A or an invalid result containing at least one error.
import cats.data.ValidatedNel
import cats.syntax.all.*
enum SignupError:
case NameRequired, EmailMalformed
final case class Signup(name: String, email: String)
type Checked[A] = ValidatedNel[SignupError, A]
def validateName(raw: String): Checked[String] =
val name = normalizeName(raw)
if name.nonEmpty then name.validNel
else SignupError.NameRequired.invalidNel
def validateEmail(raw: String): Checked[String] =
val email = raw.trim
// shortcut: this is a teaching predicate; use a real email policy for a product.
if email.contains("@") then email.validNel
else SignupError.EmailMalformed.invalidNel
def signup(name: String, email: String): Checked[Signup] =
(validateName(name), validateEmail(email)).mapN(Signup.apply)
signup(" Ada ", "ada@example.com")
// Valid(Signup(Ada,ada@example.com))
signup("", "broken")
// Invalid(NonEmptyList(NameRequired, EmailMalformed))ScalaThe email check is deliberately small: it only tests for @. It demonstrates composition, not a production email-validation policy. It accepts many malformed addresses and does not verify ownership or deliverability. These examples accept decoded, non-null strings; handle missing fields and JSON null before calling them.
validNel lifts a successful value into this validation type. invalidNel lifts one error into an invalid result with a non-empty error list. The tuple’s mapN applies Signup.apply when both values are valid. When either is invalid, it keeps the errors; when both are invalid, it combines their lists.
That combination uses a Semigroup, a type class for an associative combine operation. Here the operation joins non-empty error lists in input order. You do not need to implement the instance yourself.
The error behavior comes from the data type and its instance. Changing an Either for-comprehension into ordinary Either.mapN does not automatically accumulate errors. This example chooses ValidatedNel specifically for its accumulating combination.
Independent also does not mean concurrent. These are ordinary pure function calls that produce validation values, and mapN combines their results. It starts no threads. Cats discusses distinct combination modes in its Applicative documentation.
For a later check that needs an already validated value, Validated provides andThen. It does not provide a lawful accumulating Monad instance: the behavior needed for accumulating independent errors differs from dependent, fail-fast sequencing. The Validated guide explains that trade-off. Start by choosing the behavior your workflow needs, rather than making every validation use the same type.
The accompanying Intro.scala combines these examples and includes assertions for valid input, one invalid field, two invalid fields, absent values, and dependent quantity checks. It pins Scala 3.3.8 and Cats Core 2.13.0. These are reproducible example versions, not a requirement to change an existing application’s Scala version.
//> using scala "3.3.8"
//> using dep "org.typelevel::cats-core:2.13.0"
import cats.{Eq, Functor}
import cats.data.{NonEmptyList, ValidatedNel}
import cats.syntax.all.*ScalaThe //> lines are Scala CLI dependency directives. The displayed imports bring type class names and data types into scope, while cats.syntax.all.* enables the extension syntax used here. Current Cats supplies standard instances for types such as Option and List through implicit scope, as described in its import guidance.
In an sbt project, set scalaVersion := "3.3.8" and add libraryDependencies += "org.typelevel" %% "cats-core" % "2.13.0". Only Cats Core is needed for the examples in this article.
Expand the complete example below, save it as Intro.scala, and run scala run Intro.scala using Scala CLI. A successful run prints All Cats introduction checks passed. after the assertions pass.
//> using scala "3.3.8"
//> using dep "org.typelevel::cats-core:2.13.0"
import cats.{Eq, Functor}
import cats.data.{NonEmptyList, ValidatedNel}
import cats.syntax.all.*
object CatsIntro:
def normalizeName(raw: String): String = raw.trim
final case class AccountId(value: String)
object AccountId:
given Eq[AccountId] = Eq.by(_.value)
def normalizeInside[F[_]: Functor](values: F[String]): F[String] =
values.map(normalizeName)
enum QuantityError:
case NotInteger, NotPositive
def parseQuantity(raw: String): Either[QuantityError, Int] =
raw.toIntOption.toRight(QuantityError.NotInteger)
def requirePositive(value: Int): Either[QuantityError, Int] =
Either.cond(value > 0, value, QuantityError.NotPositive)
def quantity(raw: String): Either[QuantityError, Int] =
for
parsed <- parseQuantity(raw)
positive <- requirePositive(parsed)
yield positive
enum SignupError:
case NameRequired, EmailMalformed
final case class Signup(name: String, email: String)
type Checked[A] = ValidatedNel[SignupError, A]
def validateName(raw: String): Checked[String] =
val name = normalizeName(raw)
if name.nonEmpty then name.validNel
else SignupError.NameRequired.invalidNel
def validateEmail(raw: String): Checked[String] =
val email = raw.trim
// shortcut: this is a teaching predicate; use a real email policy for a product.
if email.contains("@") then email.validNel
else SignupError.EmailMalformed.invalidNel
def signup(name: String, email: String): Checked[Signup] =
(validateName(name), validateEmail(email)).mapN(Signup.apply)
def main(args: Array[String]): Unit =
assert(normalizeName(" Ada ") == "Ada")
assert(normalizeName(normalizeName(" Ada ")) == "Ada")
assert(AccountId("a-1") === AccountId("a-1"))
assert(AccountId("a-1") =!= AccountId("b-2"))
assert(normalizeInside(Option(" Ada ")) == Some("Ada"))
assert(normalizeInside(List(" Ada ", " Lin ")) == List("Ada", "Lin"))
assert(normalizeInside(Option.empty[String]).isEmpty)
assert(quantity("3") == Right(3))
assert(quantity("three") == Left(QuantityError.NotInteger))
assert(quantity("0") == Left(QuantityError.NotPositive))
assert(quantity("-2") == Left(QuantityError.NotPositive))
assert(quantity("99999999999999999999") == Left(QuantityError.NotInteger))
val valid = signup(" Ada ", " ada@example.com ")
val invalid = signup(" ", "broken")
assert(valid.toEither == Right(Signup("Ada", "ada@example.com")))
assert(
invalid.toEither == Left(
NonEmptyList.of(SignupError.NameRequired, SignupError.EmailMalformed)
)
)
assert(
signup("Ada", "broken").toEither ==
Left(NonEmptyList.one(SignupError.EmailMalformed))
)
assert(
signup("", "ada@example.com").toEither ==
Left(NonEmptyList.one(SignupError.NameRequired))
)
println(valid)
println(invalid)
println("All Cats introduction checks passed.")ScalaThe names are related, but they do not identify one library with three spellings.
Cats provides abstractions and data types for composition. The Eq, Functor, and ValidatedNel examples belong here.
Cats Effect provides effect abstractions, the IO type, and a runtime for executing effectful programs. Cancellation, resource safety, and concurrency become explicit parts of the design. Describing an interaction in IO is different from running that interaction. Merely putting an eager effect inside a return expression does not defer it; the construction API matters.
fs2 provides composable, effect-polymorphic streams built on Cats and Cats Effect abstractions. It is useful when a program needs to process a sequence of values with controlled effects and resource lifetimes.
A valid Signup does not itself describe a database write. Moving from pure values to a description of that write is the next conceptual boundary. This introduction stays with Cats Core so that the difference remains visible.
Pure domain functions are useful when business rules change frequently, when several entry points share those rules, and when testing through a full infrastructure stack makes feedback slow. Explicit error values help a caller distinguish expected invalid input from an unexpected failure.
There are costs. A team must learn the types and agree on error semantics. Broad syntax imports can make an unfamiliar method’s origin less obvious. Generic code can hide a simple operation behind unnecessary parameters. Typed domain errors also need deliberate translation into API responses and operational signals.
Cats does not make a database transaction atomic, make mutable shared state safe, or guarantee that a program performs well. Allocating intermediate values and accumulating unbounded errors can matter for large inputs. Measure real workloads, and put sensible limits on request sizes and validation output.
I would start with one boundary and one useful improvement: extract a pure validator, represent its expected failures, and decide whether the checks are independent or dependent. Use Either when a later step needs an earlier result. Use an accumulating validation type when independently available problems should be reported together. Introduce a generic type class constraint when there is a real shared operation to express.
That is enough foundation to begin learning Cats without memorizing its hierarchy. The code should make the decision and the failure behavior easier to understand. Continue from the Cats Effect & fs2 series page, or browse the other Scala and functional programming articles.

Senior Technical Architect at HCLTech · working with Leonteq Security AG
I have 10+ years of experience building distributed systems and fintech platforms. I write about practical, non-obvious engineering details that official documentation often skips.
Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.
Join the newsletter