Type classes, Eq, and Show

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.

What problem does a type class solve?

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 three working parts: contract, instance, and consumer

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): String

A 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.

How given, using, and context bounds fit together

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.

A type class separates a capability, its instances, and a generic consumer Read the definitions first. Label[A] declares render from A to String. The named stringLabel and intLabel instances implement that capability for String and Int. The generic labelOf function requests a Label[A]. For labelOf("orders"), the compiler selects stringLabel. At runtime its render method produces [orders]. Instance selection and runtime execution are separate steps. A capability, its instances, and a generic callerDEFINE THE PARTSCALL THE GENERIC FUNCTIONimplementsContractLabel[A]render(A): StringInstancesstringLabel: Label[String]intLabel: Label[Int]Different implementationsdef labelOf[A](value: A)(using Label[A])The consumer asks for the capability it needsCOMPILE TIMElabelOf("orders")Needs Label[String]Select stringLabelRUNTIMEstringLabel.render("orders")"[orders]"
A type class separates a capability, its instances, and a generic consumer Read the definitions first. Label[A] declares render from A to String. The named stringLabel and intLabel instances implement that capability for String and Int. The generic labelOf function requests a Label[A]. For labelOf("orders"), the compiler selects stringLabel. At runtime its render method produces [orders]. Instance selection and runtime execution are separate steps. The parts of a type classDEFINE THE PARTSTHE GENERIC CALLimplementsLabel[A]render(A): StringInstancesstringLabel: Label[String]intLabel: Label[Int]labelOf[A](value)(using Label[A])COMPILE TIMElabelOf("orders")Needs Label[String]Select stringLabelRUNTIMEstringLabel.render("orders")"[orders]"
The contract names the operation, an instance supplies it for a type, and a generic consumer requests that instance. Scala selects the instance for a call; the program then uses it as an ordinary value.

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.

How this differs from inheritance and ordinary parameters

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.

What Cats adds to this pattern

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.

Worked example: Eq is the equality capability

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) // true

The 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")) // true

For 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 applies
Structural and ID-only equality answer different questions The same pair of Account values has equal IDs but different display names. A structural relation comparing both fields returns false. An explicitly selected ID-only relation returns true. These are alternative policies, not two active default instances. One pair of values, two explicit equality policiesSAME TWO VALUESA: id = a-1displayName = AdaB: id = a-1displayName = Ada LovelaceStructural policyCompare ID + display namefalseID-only policyCompare only the IDtrueChoose the relation explicitly. These are alternative policies.
Structural and ID-only equality answer different questions The same pair of Account values has equal IDs but different display names. A structural relation comparing both fields returns false. An explicitly selected ID-only relation returns true. These are alternative policies, not two active default instances. Equality is a domain decisionSAME TWO VALUESAccount Aa-1 · AdaAccount Ba-1 · Ada LovelaceStructuralpolicyID + namefalseID-onlypolicyID onlytrueChoose one relation explicitly.These are alternative policies.
The relation determines the answer. Keep one clear default instance and choose a different comparison policy explicitly when the use case needs it.

The 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.

Worked example: Show is the rendering capability

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)"
Show redaction affects explicit uses of the selected instance An ApiCredential retains its owner and secret token. The explicit show branch uses a redacted Show instance and produces text with the token replaced by a redacted marker. A separate dashed toString branch does not use Show and may expose the token. Other logging and serialization paths require their own policy. A Show instance controls explicit renderingApiCredential(owner, token)The original value still contains sensitive datacredential.showUses the redacted Showcredential.toStringDoes not use Showowner=workertoken=[redacted]May expose token
Show redaction affects explicit uses of the selected instance An ApiCredential retains its owner and secret token. The explicit show branch uses a redacted Show instance and produces text with the token replaced by a redacted marker. A separate dashed toString branch does not use Show and may expose the token. Other logging and serialization paths require their own policy. Show is an explicit output choiceApiCredential(owner, token)Contains sensitive datacredential.showUses Showcredential.toStringBypasses Showowner=workertoken=[redacted]May exposetokenExplicit .show calls only.Other output paths need their own policy.
Redaction applies to explicit .show calls using this instance. It does not automatically change toString, logging integrations, or serialization.

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.

A map of the other common Cats type classes

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.

Common Cats type classes describe capabilities for values and type constructors This orientation groups capabilities without drawing an inheritance hierarchy. Eq, Order, Show, Semigroup, and Monoid operate on value types A. Functor, Apply, Applicative, Monad, Foldable, and Traverse describe operations for type constructors F with one type parameter. Option, Either, and Validated are data types that can have suitable type class instances. A map of common Cats type classesChoose the capability a function needs.VALUE TYPES ATYPE CONSTRUCTORS F[_]EqCompare for equalityOrderCompare values in orderShowRender as textSemigroupCombine two valuesMonoidCombine plus an empty valueFunctorMap a value in a contextApplyCombine contextual valuesApplicativeAdd pure to combinationMonadSequence dependent stepsFoldableAggregate contained valuesTraverseVisit values with an effectExamples: Int, AccountIdType classes describe operations.Option, Either, and Validated are data types.This grouping is not an inheritance tree.
Common Cats type classes describe capabilities for values and type constructors This orientation groups capabilities without drawing an inheritance hierarchy. Eq, Order, Show, Semigroup, and Monoid operate on value types A. Functor, Apply, Applicative, Monad, Foldable, and Traverse describe operations for type constructors F with one type parameter. Option, Either, and Validated are data types that can have suitable type class instances. Two kinds of capabilityA practical orientation, not a hierarchy.VALUE TYPES ATYPE CONSTRUCTORS F[_]EqCompare for equalityOrderCompare values in orderShowRender as textSemigroupCombine two valuesMonoidCombine plus an empty valueFunctorMap a value in a contextApplyCombine contextual valuesApplicativeAdd pure to combinationMonadSequence dependent stepsFoldableAggregate contained valuesTraverseVisit values with an effectType classes describe operations.Option, Either, and Validatedare data types.This is not an inheritance tree.
This is a practical grouping of capabilities, not the full Cats hierarchy. Type classes describe operations; data types such as Option, Either, and Validated can have instances of those type classes.

For complete value types, these contracts are common:

Type classCapabilityQuestion it answers
Eq[A]EqualityAre these values equal under this policy?
Order[A]Total orderingDoes one value come before, equal to, or after another?
Show[A]Text renderingWhich explicit string representation should I use?
Semigroup[A]Associative combinationHow do I combine two values of this type?
Monoid[A]Combination plus an identity valueWhat 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 classCapabilityTypical operation
Functor[F]Transform a value within a contextmap
Apply[F]Combine contextual valuesmap2, tuple mapN syntax
Applicative[F]Lift ordinary values and combine contextspure plus Apply’s operations
Monad[F]Sequence steps whose next computation depends on a resultflatMap and pure
Foldable[F]Summarize contained valuesfoldLeft and other folds
Traverse[F]Visit elements with contextual results, collecting them togethertraverse

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.

A type class is different from a data type

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.

Laws make the shared contract meaningful

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.

Run the complete example

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.

Complete runnable EqShow.scala and checks
//> 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.

Share
Prabhat Kashyap

Prabhat Kashyap

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.

Newsletter

Get new engineering deep dives

Scala, JVM, AI, and backend systems. I send practical articles when there is something worth reading.

Join the newsletter