What type classes are, why they help, and how given, using, and context bounds work. Then apply the pattern to Cats Eq, Show, and other core capabilities.
What type classes are, why they help, and how given, using, and context bounds work. Then apply the pattern to Cats Eq, Show, and other core capabilities.
A type class describes an operation that a type can support, while keeping that operation’s implementation separate from the type itself. A function can then ask for the capability it needs: “I can work with any A, provided you give me a way to render it.”
That separation is the main idea behind type classes in Scala. Cats supplies established contracts for common capabilities, along with implementations for familiar types. Eq and Show are two approachable examples, but the same pattern extends to combining values, mapping through a context, and sequencing computations.
This second article in the Cats, Cats Effect & fs2 series starts with the pattern itself. I will build a small type class without Cats, explain how Scala finds its implementation, then apply that model to Eq, Show, and the other common Cats abstractions. It follows Functional programming and Cats.
Suppose a diagnostic formatter needs to produce a label for different kinds of values. A string might become [orders]. An integer might become count=42. The formatting operation is shared, but its implementation depends on the type.
One option is to make every participating type extend an interface with a rendering method. That can work for types I own. It becomes awkward for existing library types such as String and Int, whose definitions I cannot change. It also makes the data type responsible for every capability that its consumers might need.
A type class puts the implementation in a separate value. A Label[String] knows how to label strings. A Label[Int] knows how to label integers. A generic formatter receives whichever implementation matches its input type.
This is a form of ad hoc polymorphism: the same operation can have different implementations for different types. “Ad hoc” names this style of polymorphism; it does not mean the implementations are unstructured. Each one follows a shared contract.
The Scala pattern has three working parts. The type class declares an operation. An instance implements it for a particular type. A consumer asks for the instance and uses its operation. Extension syntax is an optional convenience on top.
Start with the contract:
trait Label[A]:
def render(value: A): StringA is a type parameter. For Label[String], render accepts a string. For Label[Int], it accepts an integer. The trait does not decide how either value should be formatted.
In Scala, a type class is a pattern built with ordinary language features. There is no special typeclass declaration. The trait above can represent the contract; a separate implementation supplies the behavior.
Now define two instances:
given stringLabel: Label[String] with
def render(value: String): String = s"[$value]"
given intLabel: Label[Int] with
def render(value: Int): String = s"count=$value"Each given creates a value implementing one concrete version of the trait and makes it available for contextual lookup. stringLabel is a Label[String]; intLabel is a Label[Int]. Neither String nor Int inherits from Label.
Finally, write the consumer:
def labelOf[A](value: A)(using label: Label[A]): String =
label.render(value)The ordinary parameter is value: A. The contextual parameter is label: Label[A]. They are two separate inputs. The function does not inspect the value’s runtime class or contain separate branches for strings and integers. It delegates to the implementation it receives.
The using clause requests that implementation. At a call site, Scala can supply a suitable given:
labelOf("orders") // "[orders]"
labelOf(42) // "count=42"
labelOf("orders")(using stringLabel) // "[orders]"
summon[Label[String]].render("orders") // "[orders]"The same labelOf function works for both inputs. Its type parameter changes, and so does the supplied instance. The named using stringLabel call makes the second argument explicit. summon[Label[String]] asks the compiler for the available contextual value directly.
Without a suitable instance, the call does not compile. In this example, labelOf(true) is rejected because there is no Label[Boolean]. The compiler is checking whether the required capability is available. It is not guessing how a Boolean should be rendered. Scala’s type class guide develops this pattern further.
given provides a contextual value. using requests one. Neither keyword by itself means “type class”: contextual parameters can carry other kinds of values too. A type class uses these features to pass an implementation of a capability.
A context bound gives a shorter way to express that requirement:
def labelBound[A: Label](value: A): String =
summon[Label[A]].render(value)
labelBound("orders") // "[orders]"
labelBound(42) // "count=42"For this example, A: Label requests a contextual Label[A], just as the earlier using parameter did. Because this version does not give that parameter a name, summon retrieves it. The Scala context-bound guide explains the shorthand.
The compiler resolves the required instance while type checking the call. The running program then calls an ordinary method on the supplied value. Compile-time selection and runtime execution are separate steps. A type class does not require a runtime search through all implementations.
Instances in the current lexical scope, explicitly imported givens, and the implicit scope of the involved types can participate in lookup. A companion object is a useful home for a domain type’s canonical instances. If several candidates are available, Scala applies its resolution rules; candidates it cannot disambiguate produce an error. Prefer predictable scope over clever selection. The contextual-parameter documentation covers explicit passing and lookup.
With subtype polymorphism, a function works through a shared parent interface implemented by its values. With a type class, a function keeps the original value type A and receives a separate implementation for that type. Both approaches can use traits and ordinary objects internally. Their difference is how a value participates in the capability.
The separation lets me add behavior for a type I do not own, keep several independent capabilities for one type, and write functions that require only the operations they use. An account identifier can have equality and rendering instances without becoming a subtype of either capability.
A type class is still ordinary dependency passing. If one method needs one formatting function, passing A => String directly may be simpler. The type class pattern becomes useful when the same capability has a reusable contract, many consumers, and instances that should compose across other types.
It does not make every generic function useful. Start with an actual shared operation, then express its requirements. An unconstrained A does not provide arbitrary methods merely because the function is generic.
Cats supplies reusable type class contracts, standard instances, syntax, and associated laws. I do not need to invent a new equality or mapping interface for each application.
The correspondence is direct: Label[A] was the custom contract; Eq[A] and Show[A] are Cats contracts. The named given values were instances; the Cats instances below work the same way. Generic consumers request Eq[A] or Show[A] instead of Label[A].
Syntax is a fourth, optional layer. An expression such as a === b is a convenient way to call an equality operation. Importing syntax does not define the comparison policy. The selected instance does.
Eq[A] declares eqv(left, right): Boolean. Define it for an account identifier:
import cats.Eq
import cats.syntax.all.*
final case class AccountId(value: String)
object AccountId:
given Eq[AccountId] = Eq.by(_.value)
val first = AccountId("a-1")
val another = AccountId("a-1")
first === another // true
first =!= AccountId("b-2") // true
Eq[AccountId].eqv(first, another) // trueThe case class holds the data. The companion’s given Eq[AccountId] is a separate implementation. Eq.by(_.value) compares the underlying strings using their existing Eq instance. The syntax import enables === and =!=; the direct Eq[AccountId].eqv call exposes the same dependency.
A generic equality consumer follows the same pattern as labelOf:
import cats.Eq
def same[A: Eq](left: A, right: A): Boolean =
Eq[A].eqv(left, right)
same(AccountId("a-1"), AccountId("a-1")) // trueFor these definitions, AccountId("a-1") === "a-1" is rejected: the right operand is a string, while this comparison expects an account identifier. This catches one kind of accidental comparison. Scala’s own strict-equality feature is separate from Cats’ Eq.
The instance also defines a policy. Comparing account snapshots by every field differs from asking whether they refer to the same account:
final case class Account(id: AccountId, displayName: String)
object Account:
given Eq[Account] = Eq.instance { (left, right) =>
left.id === right.id && left.displayName === right.displayName
}
val sameAccount: Eq[Account] = Eq.by(_.id)
val before = Account(AccountId("a-1"), "Ada")
val renamed = Account(AccountId("a-1"), "Ada Lovelace")
before === renamed // false: snapshots differ
sameAccount.eqv(before, renamed) // true: same identity
Set(before, renamed).size // 2: standard equality still appliesThe canonical instance compares both fields. The explicitly named sameAccount comparator compares only IDs. It is a plain value, not a competing default given. This keeps the alternative policy visible at its call site.
Defining Eq does not change a case class’s equals or hashCode. Ordinary hash-based Scala collections use those contracts, rather than Cats’ instance. The two snapshots remain distinct entries in the demonstrated Set. An identity-keyed Map[AccountId, Account] is often clearer when identity is the requirement.
Show[A] declares how to turn an A into a string. Add its instance to the same companion:
import cats.{Eq, Show}
import cats.syntax.all.*
final case class AccountId(value: String)
object AccountId:
given Eq[AccountId] = Eq.by(_.value)
given Show[AccountId] = Show.show(id => s"account/${id.value}")
AccountId("a-1").show // "account/a-1"
AccountId("a-1").toString // "AccountId(a-1)"The account identifier now has two independent capabilities. Eq compares it; Show renders it. Neither requires changing the identifier’s inheritance. Show.show constructs an instance from a function, and .show is syntax for using the selected instance.
A generic formatter can ask for that capability:
def report[A: Show](value: A): String = s"value=${value.show}"
def reportExplicit[A](value: A)(using renderer: Show[A]): String =
s"value=${renderer.show(value)}"
report(AccountId("a-1")) // "value=account/a-1"A: Show and using renderer: Show[A] express the same requirement here. The consumer depends on the rendering contract, rather than on a particular domain class. The complete source also demonstrates passing a different renderer explicitly for one call.
Show does not override toString. Plain s"$id" interpolation does not automatically use it. A redacted Show instance therefore affects only the output paths that select it:
final case class ApiCredential(owner: String, token: String)
object ApiCredential:
given Show[ApiCredential] =
Show.show(c => s"owner=${c.owner} token=[redacted]")
val credential = ApiCredential("worker", "not-a-real-token")
credential.show // "owner=worker token=[redacted]"
credential.toString // "ApiCredential(worker,not-a-real-token)"The token above is fictional. The example demonstrates the boundary: the chosen .show representation omits the token, but generated toString still includes it. Show provides no automatic logging, escaping, serialization, or secret-handling guarantee.
The pattern remains the same beyond Eq and Show. What changes is the capability being requested. It helps to separate contracts for a complete value type A from contracts for a type constructor F[_].
AccountId and String are complete types. Option and List each need a type argument: supplying Int gives Option[Int] and List[Int]. Thus Functor[Option] describes mapping for the Option context, while Eq[AccountId] describes equality for account identifiers.
For complete value types, these contracts are common:
| Type class | Capability | Question it answers |
|---|---|---|
Eq[A] | Equality | Are these values equal under this policy? |
Order[A] | Total ordering | Does one value come before, equal to, or after another? |
Show[A] | Text rendering | Which explicit string representation should I use? |
Semigroup[A] | Associative combination | How do I combine two values of this type? |
Monoid[A] | Combination plus an identity value | What empty value lets me combine zero or more values? |
For example, string concatenation combines two strings. Combining lists appends their elements, and an empty list acts as an identity: combining with it changes nothing. A Monoid adds that identity to a Semigroup‘s associative operation. Associativity lets parentheses move without changing the result; it does not mean the order of the inputs can be changed. See the Cats guides to Semigroup and Monoid.
For contexts and containers, these contracts are useful:
| Type class | Capability | Typical operation |
|---|---|---|
Functor[F] | Transform a value within a context | map |
Apply[F] | Combine contextual values | map2, tuple mapN syntax |
Applicative[F] | Lift ordinary values and combine contexts | pure plus Apply’s operations |
Monad[F] | Sequence steps whose next computation depends on a result | flatMap and pure |
Foldable[F] | Summarize contained values | foldLeft and other folds |
Traverse[F] | Visit elements with contextual results, collecting them together | traverse |
Functor lets a function transform the contents without choosing how the context behaves. Mapping an absent Option leaves it absent. Applicative can combine independently supplied contextual values, while Monad supports choosing the next computation from an earlier result. Combining independent values does not imply concurrent execution.
Foldable aggregates values, such as summing a list. Traverse can apply a parser returning Option[Int] to each string and collect the result as Option[List[Int]]. If a parse is absent, the combined result is absent. This differs from plain map, which would leave a list of individual options.
These short checks put the names next to concrete operations:
import cats.{Applicative, Apply, Foldable, Functor, Monad, Monoid, Order, Semigroup, Traverse}
import cats.syntax.all.*
def overviewChecks(): Unit =
assert(Order[Int].compare(1, 2) < 0)
assert(Semigroup[String].combine("cat", "s") == "cats")
assert(Monoid[List[Int]].empty == List.empty[Int])
assert(Monoid[List[Int]].combine(List(1), List(2)) == List(1, 2))
assert(Functor[Option].map(Option(2))(_ + 1) == Some(3))
assert(Apply[Option].map2(Option(2), Option(3))(_ + _) == Some(5))
assert((Option(2), Option(3)).mapN(_ + _) == Some(5))
assert(Applicative[Option].pure(3) == Some(3))
assert(Monad[Option].flatMap(Option(2))(n => Option(n + 1)) == Some(3))
assert(Foldable[List].foldLeft(List(1, 2, 3), 0)(_ + _) == 6)
assert(Traverse[List].traverse(List("1", "2"))(_.toIntOption) == Some(List(1, 2)))
assert(Traverse[List].traverse(List("1", "bad"))(_.toIntOption).isEmpty)
overviewChecks()The map above groups capabilities; it is not a complete inheritance diagram. Some contracts extend others: Applicative includes Apply, Monad includes Applicative and FlatMap, and Traverse combines Functor and Foldable. There is no single linear ladder that every program must climb.
Option, Either, and Validated represent data or computation results. Functor, Monad, and Traverse describe operations that suitable types can support. Cats Effect’s IO is also a data type, rather than a type class.
For example, Option has a Monad instance, and an appropriate Either context can have one too. That lets generic code depend on the shared sequencing contract. The contract does not make their success and failure representations identical.
Instances also compose. Cats can obtain Eq[Option[AccountId]] or Show[List[AccountId]] from instances for AccountId. This is another reason to make the capability explicit: library code can reuse it when the value is wrapped in other structures. Current Cats provides standard instances through implicit scope; its import guidance distinguishes them from extension syntax.
A compatible method signature is only part of an instance. Equality should be reflexive, symmetric, and transitive. A semigroup’s combination should be associative. Mapping the identity function through a functor should change nothing. These laws explain what generic consumers can reasonably assume.
The compiler checks types and instance availability. It does not prove these behavioral laws. An equality policy based on values being within five seconds of one another, for example, can violate transitivity. The complete example checks equality laws on finite samples; those checks are useful evidence, not a proof for every input.
Choose the smallest capability a function needs. A function that only maps should usually request Functor, rather than demanding all of Monad. That states its actual dependency and permits more implementations. It also avoids introducing a stronger abstraction before the code has a reason to use it.
Expand the source below, save it as EqShow.scala, and run scala run EqShow.scala using Scala CLI. The file pins Scala 3.3.8 and Cats Core 2.13.0. It includes the custom type class, Cats instances, overview checks, and assertions. No Cats Effect dependency is needed.
//> using scala "3.3.8"
//> using dep "org.typelevel::cats-core:2.13.0"
import cats.{Applicative, Apply, Eq, Foldable, Functor, Monad, Monoid, Order, Semigroup, Show, Traverse}
import cats.syntax.all.*
object EqShow:
trait Label[A]:
def render(value: A): String
given stringLabel: Label[String] with
def render(value: String): String = s"[$value]"
given intLabel: Label[Int] with
def render(value: Int): String = s"count=$value"
def labelOf[A](value: A)(using label: Label[A]): String =
label.render(value)
def labelBound[A: Label](value: A): String =
summon[Label[A]].render(value)
final case class AccountId(value: String)
object AccountId:
given Eq[AccountId] = Eq.by(_.value)
given Show[AccountId] = Show.show(id => s"account/${id.value}")
def same[A: Eq](left: A, right: A): Boolean =
Eq[A].eqv(left, right)
final case class Account(id: AccountId, displayName: String)
object Account:
given Eq[Account] = Eq.instance { (left, right) =>
left.id === right.id && left.displayName === right.displayName
}
val sameAccount: Eq[Account] = Eq.by(_.id)
def report[A: Show](value: A): String = s"value=${value.show}"
def reportExplicit[A](value: A)(using renderer: Show[A]): String =
s"value=${renderer.show(value)}"
def sameOptionalId(left: Option[AccountId], right: Option[AccountId]): Boolean =
left === right
def showIds(ids: List[AccountId]): String = ids.show
final case class ApiCredential(owner: String, token: String)
object ApiCredential:
given Show[ApiCredential] =
Show.show(c => s"owner=${c.owner} token=[redacted]")
def overviewChecks(): Unit =
assert(Order[Int].compare(1, 2) < 0)
assert(Semigroup[String].combine("cat", "s") == "cats")
assert(Monoid[List[Int]].empty == List.empty[Int])
assert(Monoid[List[Int]].combine(List(1), List(2)) == List(1, 2))
assert(Functor[Option].map(Option(2))(_ + 1) == Some(3))
assert(Apply[Option].map2(Option(2), Option(3))(_ + _) == Some(5))
assert((Option(2), Option(3)).mapN(_ + _) == Some(5))
assert(Applicative[Option].pure(3) == Some(3))
assert(Monad[Option].flatMap(Option(2))(n => Option(n + 1)) == Some(3))
assert(Foldable[List].foldLeft(List(1, 2, 3), 0)(_ + _) == 6)
assert(Traverse[List].traverse(List("1", "2"))(_.toIntOption) == Some(List(1, 2)))
assert(Traverse[List].traverse(List("1", "bad"))(_.toIntOption).isEmpty)
def checkEqLaws[A: Eq](samples: List[A]): Unit =
val relation = Eq[A]
for x <- samples do
assert(relation.eqv(x, x))
for y <- samples do
assert(relation.eqv(x, y) == relation.eqv(y, x))
for z <- samples do
if relation.eqv(x, y) && relation.eqv(y, z) then
assert(relation.eqv(x, z))
def main(args: Array[String]): Unit =
val first = AccountId("a-1")
val sameId = AccountId("a-1")
val other = AccountId("b-2")
assert(labelOf("orders") == "[orders]")
assert(summon[Label[String]].render("orders") == "[orders]")
assert(labelOf("orders")(using stringLabel) == "[orders]")
assert(labelOf(42) == "count=42")
assert(summon[Label[Int]].render(42) == "count=42")
assert(labelBound("orders") == "[orders]")
assert(labelBound(42) == "count=42")
assert(first === sameId)
assert(first =!= other)
assert(Eq[AccountId].eqv(first, sameId))
assert(summon[Eq[AccountId]].eqv(first, sameId))
assert(same(first, sameId))
assert(!same(first, other))
val before = Account(first, "Ada")
val renamed = Account(first, "Ada Lovelace")
assert(before =!= renamed)
assert(sameAccount.eqv(before, renamed))
assert(before != renamed)
assert(Set(before, renamed).size == 2)
assert(first.show == "account/a-1")
assert(Show[AccountId].show(first) == "account/a-1")
assert(report(first) == "value=account/a-1")
assert(reportExplicit(first) == report(first))
val concise: Show[AccountId] = Show.show(_.value)
assert(reportExplicit(first)(using concise) == "value=a-1")
assert(first.show == "account/a-1")
val present: Option[AccountId] = Some(first)
val absent: Option[AccountId] = None
assert(present =!= absent)
assert(sameOptionalId(Some(first), Some(sameId)))
assert(!sameOptionalId(Some(first), None))
assert(sameOptionalId(None, None))
assert(showIds(List(first, other)) == "List(account/a-1, account/b-2)")
assert(List(first, other) === List(sameId, other))
assert(List(first, other) =!= List(other, first))
assert(Option(first).show == "Some(account/a-1)")
val credential = ApiCredential("worker", "not-a-real-token")
assert(credential.show == "owner=worker token=[redacted]")
assert(!credential.show.contains(credential.token))
assert(credential.toString.contains(credential.token))
overviewChecks()
checkEqLaws(List(first, sameId, other))
checkEqLaws(List(before, renamed, before.copy()))
checkEqLaws(List(before, renamed, before.copy()))(using sameAccount)
println(labelOf("orders"))
println(report(first))
println(showIds(List(first, other)))
println(credential.show)
println("All type class, Eq, and Show checks passed.")When reading unfamiliar Cats code, I start with three questions: what capability is requested, which instance supplies it for this type, and which operation does the consumer call? given, using, context bounds, and syntax become easier to read once those responsibilities are separate.
Continue from the Cats, 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