Traits, Givens, and Type Classes
Mix in behaviour with traits, add methods to types you do not own with extensions, and let the compiler pass arguments for you with given and using.
Traits are Scala’s unit of reusable behaviour. Givens are the compiler passing arguments you did not write. Together they give you type classes — a way to add capabilities to types without touching or inheriting from them.
Traits
trait Priced:
def amount: Double // abstract
def currency: String = "GBP" // concrete, overridable
def display: String = f"$currency $amount%.2f"
trait Timestamped:
def createdAt: java.time.LocalDate
def isRecent: Boolean =
createdAt.isAfter(java.time.LocalDate.of(2026, 1, 1))
case class Order(id: Int, amount: Double, createdAt: java.time.LocalDate)
extends Priced, Timestamped
case class Refund(id: Int, amount: Double, createdAt: java.time.LocalDate)
extends Priced, Timestamped:
override def display = s"-${super.display}"
@main def run(): Unit =
val o = Order(1001, 25.50, java.time.LocalDate.of(2026, 1, 4))
val r = Refund(9001, 40.00, java.time.LocalDate.of(2025, 12, 20))
println(o.display)
println(r.display)
println(o.isRecent, r.isRecent)
val priced: List[Priced] = List(o, r)
println(priced.map(_.amount).sum)
GBP 25.50
-GBP 40.00
(true,false)
65.5
Two traits mixed into one class with a comma — no single-inheritance limit. Each contributes an abstract member the class must supply and concrete methods it gets for free.
Scala 3 traits can take parameters:
trait Auditable(val source: String):
def auditLine(id: Int): String = s"[$source] record $id"
case class Payment(id: Int) extends Auditable("payments-api")
@main def run(): Unit =
println(Payment(1).auditLine(1))
[payments-api] record 1
Stacking
When several traits define the same method, the mixin order decides what runs. Scala linearises right to left:
trait Step:
def run(x: Int): Int
trait AddTax extends Step:
abstract override def run(x: Int) = super.run(x) + 20
trait Log extends Step:
abstract override def run(x: Int) =
val r = super.run(x)
println(s" log: $x -> $r")
r
class Base extends Step:
def run(x: Int) = x
@main def run(): Unit =
val a = new Base with AddTax with Log
val b = new Base with Log with AddTax
println(a.run(100))
println(b.run(100))
log: 100 -> 120
120
log: 100 -> 100
120
Same traits, different order, different behaviour: in a the log sees the taxed value, in
b it sees the untaxed one. abstract override marks a method that calls super on
something still abstract — the mechanism behind stackable modifications, and a good reason to
keep mixin chains short.
Extension methods
extension (s: String)
def toSlug: String = s.trim.toLowerCase.replaceAll("[^a-z0-9]+", "-").stripSuffix("-")
def truncate(n: Int): String = if s.length <= n then s else s.take(n - 1) + "…"
extension (d: Double)
def gbp: String = f"£$d%.2f"
def withVat: Double = d * 1.20
@main def run(): Unit =
println("The Mythical Man-Month".toSlug)
println("Structure and Interpretation of Computer Programs".truncate(20))
println(25.50.gbp)
println(25.50.withVat.gbp)
the-mythical-man-month
Structure and Inter…
£25.50
£30.60
Methods on String and Double without owning either type. They are resolved at compile
time and compiled to static calls, so there is no wrapper object allocated per use.
Givens and using
case class Config(vatRate: Double, currency: String)
def formatTotal(amount: Double)(using cfg: Config): String =
f"${cfg.currency}${amount * (1 + cfg.vatRate)}%.2f"
def lineItem(name: String, amount: Double)(using Config): String =
s"$name: ${formatTotal(amount)}"
@main def run(): Unit =
given Config = Config(0.20, "£")
println(formatTotal(25.50))
println(lineItem("SICP", 25.50))
println(formatTotal(25.50)(using Config(0.00, "$")))
£30.60
SICP: £30.60
$25.50
lineItem never mentions the config by name — it declares using Config and passes it on
implicitly. That is the point: a value threaded through a whole call chain without appearing
in every signature.
The cost is that a reader cannot see where it came from, so use givens for genuinely ambient context — configuration, an execution context, a serialiser — not to avoid typing an argument.
Type classes
The pattern that makes all of this worth learning. Define a capability as a trait over a type parameter:
trait Csv[A]:
def header: String
def row(a: A): String
object Csv:
def apply[A](using c: Csv[A]): Csv[A] = c
given Csv[Order] with
def header = "id,amount,created_at"
def row(o: Order) = s"${o.id},${o.amount},${o.createdAt}"
given Csv[Refund] with
def header = "refund_id,amount"
def row(r: Refund) = s"${r.id},${-r.amount}"
def toCsv[A: Csv](rows: List[A]): String =
(Csv[A].header :: rows.map(Csv[A].row)).mkString("\n")
@main def run(): Unit =
val orders = List(
Order(1001, 25.50, java.time.LocalDate.of(2026, 1, 4)),
Order(1002, 12.00, java.time.LocalDate.of(2026, 1, 5)),
)
println(toCsv(orders))
println()
println(toCsv(List(Refund(9001, 40.00, java.time.LocalDate.of(2026, 1, 7)))))
id,amount,created_at
1001,25.5,2026-01-04
1002,12.0,2026-01-05
refund_id,amount
9001,-40.0
Order and Refund do not extend anything and know nothing about CSV. The capability is
attached from outside, which means it works for types you do not own:
given Csv[String] with
def header = "value"
def row(s: String) = s""""${s.replace("\"", "\"\"")}""""
@main def run(): Unit =
println(toCsv(List("SICP", "The \"Mythical\" Man-Month")))
value
"SICP"
"The ""Mythical"" Man-Month"
[A: Csv] is a context bound — shorthand for (using Csv[A]). Ask for the instance
explicitly with summon:
def headerOf[A: Csv]: String = summon[Csv[A]].header
A missing instance is a compile error naming exactly what is absent:
-- Error: csv.scala:31:12 ------------------------------------------
31 | println(toCsv(List(1, 2, 3)))
| ^
|No given instance of type Csv[Int] was found for a context parameter of method toCsv
Compare that with a runtime ClassCastException or a serialiser that silently emits
{} — the error arrives before the program runs and says which instance to write.
Where instances live
The compiler looks in the companion object of the type class and of the type, so both of these are found without an import:
object Csv:
given Csv[Order] with ... // companion of the type class
case class Invoice(id: Int)
object Invoice:
given Csv[Invoice] with ... // companion of the type
Put instances in one of those two places by default. Anything else needs an explicit import at every use site, which is occasionally what you want — two competing formats for the same type — and otherwise just friction.
Practice
1. Mix two traits into a class and call inherited methods.
case class Subscription(id: Int, amount: Double, createdAt: java.time.LocalDate)
extends Priced, Timestamped
println(Subscription(7, 9.99, java.time.LocalDate.of(2026, 2, 1)).display)
GBP 9.99
The class supplies amount and createdAt; display and isRecent come free. Adding a
third trait is another comma, not a redesign.
2. Add an extension method to a type you do not own.
extension (xs: List[Double])
def average: Option[Double] = if xs.isEmpty then None else Some(xs.sum / xs.size)
println(List(25.5, 12.0, 40.0).average)
println(List.empty[Double].average)
Some(25.833333333333332)
None
Returning Option rather than dividing by zero — an extension is a good place to fix an API
that would otherwise produce NaN.
3. Write a type class instance for your own type.
case class Customer(id: Int, name: String)
object Customer:
given Csv[Customer] with
def header = "id,name"
def row(c: Customer) = s"${c.id},${c.name}"
println(toCsv(List(Customer(1, "Ada"), Customer(2, "Grace"))))
id,name
1,Ada
2,Grace
In the companion object, so no import is needed anywhere. toCsv was not touched.
4. Call a method needing a given without providing one.
No given instance of type Csv[Int] was found for a context parameter of method toCsv
A compile error naming the missing instance. This is what makes type classes safe at scale — adding a new type to a serialisation path cannot be forgotten, because the code will not build.
Next: futures and concurrency — composing asynchronous work without blocking.