Typeclasses in practical Scala: behaviour without inheritance
How a typeclass separates behaviour from data, how Scala 2 implicits and Scala 3 given instances supply evidence, and when the pattern is worth using.
A typeclass describes behaviour for a type
In Scala, a typeclass is a design pattern rather than a special declaration. A parameterised trait describes an operation for A, and a separate value implements that operation for a particular A. Generic code receives the implementation as an implicit parameter in Scala 2 or a using parameter in Scala 3.
The data type does not have to extend the typeclass. This allows behaviour to be defined for types you do not own and permits more than one interpretation where the application makes the choice explicit.
Unlike subtype polymorphism, the relationship is assembled at the call site. The compiler searches the available implicit scope for evidence that the requested behaviour exists. That convenience is useful only while instance ownership and imports remain understandable.
trait Display[A] {
def display(value: A): String
}
final case class AccountId(value: String)
object AccountId {
implicit val accountIdDisplay: Display[AccountId] =
new Display[AccountId] {
override def display(value: AccountId): String = value.value
}
}
def label[A](value: A)(implicit display: Display[A]): String =
display.display(value)
label(AccountId("a-17")) // "a-17"Companion placement gives instances a discoverable home
Scala 2 implicit search considers values available locally, imported values and relevant companion objects. Placing the canonical instance beside the data type or typeclass lets callers use it without a wildcard import from an unrelated utility object.
Local instances can deliberately override behaviour for one scope, but competing imports often produce ambiguity. If an application genuinely needs several interpretations—an internal display and a redacted display, for example—use clearly named modules and import the selected policy close to use.
An instance is application behaviour, not harmless wiring. Equality, ordering, serialisation and retry policies can change program results. Give important instances direct tests.
object RedactedAccountDisplay {
implicit val redacted: Display[AccountId] =
new Display[AccountId] {
override def display(value: AccountId): String =
s"***${value.value.takeRight(4)}"
}
}
{
import RedactedAccountDisplay.redacted
label(AccountId("account-a-17")) // "***a-17"
}Syntax makes the abstraction usable
Typeclass syntax is usually an implicit class in Scala 2 or an extension method in Scala 3. It turns display.display(account) into account.display while still resolving the separate Display[Account] instance.
Keep syntax thin. It should delegate to the typeclass rather than contain a second implementation. Otherwise direct typeclass use and extension syntax can disagree.
Libraries such as Cats use this structure for Eq, Show, Semigroup and many other abstractions. Understanding the small pattern makes library syntax less mysterious: an operator normally requires an instance expressing the law or behaviour behind it.
object DisplaySyntax {
implicit final class DisplayOps[A](private val value: A) extends AnyVal {
def display(implicit instance: Display[A]): String =
instance.display(value)
}
}
import DisplaySyntax._
AccountId("a-17").displayInstances can be derived from other instances
Typeclasses become powerful when an instance for a larger structure can be constructed from instances for its parts. If A can be displayed, Option[A] can be displayed without knowing anything else about A.
This is ordinary dependency composition performed through types. The compiler resolves Display[A], passes it to the deriving method and receives Display[Option[A]]. More advanced functional libraries apply the same idea across nested contexts and algebraic structures.
Recursive derivation can produce confusing compiler errors when a base instance is missing or candidates overlap. Inspect the required type from the outside in and supply explicit instances at important module boundaries rather than adding broad fallback implicits.
implicit def optionDisplay[A](implicit A: Display[A]): Display[Option[A]] =
new Display[Option[A]] {
override def display(value: Option[A]): String =
value.fold("not supplied")(A.display)
}
label(Option(AccountId("a-17"))) // "a-17"
label(Option.empty[AccountId]) // "not supplied"Scala 3 changes the vocabulary, not the core pattern
Scala 3 expresses instances with given, dependencies with using and syntax with extension. summon retrieves an instance from context. The separation remains the same: a behaviour interface, implementations for concrete types and generic functions that require evidence.
During a Scala 2 to Scala 3 migration, preserve behaviour before rewriting every implicit into new syntax. Instance-resolution rules and imports differ in places, so compile success should be supported by tests for code where instance choice affects results.
A mixed codebase can adopt the new vocabulary gradually where source compatibility and team conventions permit it. Clarity of instance ownership matters more than whether the syntax looks maximally Scala 3-native.
trait Display[A]:
def display(value: A): String
given Display[AccountId] with
def display(value: AccountId): String = value.value
extension [A](value: A)
def display(using instance: Display[A]): String =
instance.display(value)
AccountId("a-17").displayReach for a typeclass when generic behaviour is real
A typeclass earns its complexity when several data types share an operation, generic code needs that operation, or behaviour must be added without changing the data types. It can also express capabilities such as encoding, equality or combination in a reusable way.
For one service with one implementation, constructor injection is often simpler. For behaviour that inherently belongs to a domain object, a regular method may be clearer. Typeclasses should solve a polymorphism or composition problem, not merely relocate a method.
The practical test is whether callers benefit from writing code in terms of A plus a capability. If there is no useful generic caller, the extra trait, instance and syntax layer may be abstraction without leverage.