Skip to main content
Scala intermediate Lesson 6 of 10

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.

Frequently Asked Questions

What is the difference between a trait and an abstract class in Scala?
A class can mix in many traits but extend only one class. Traits can have abstract and concrete members and, in Scala 3, parameters. Use a trait by default; use an abstract class when you need constructor parameters evaluated once or Java interoperability.
What are given and using in Scala 3?
They replace `implicit`. A `given` defines a value the compiler may supply automatically; a `using` parameter is one the compiler fills from the givens in scope. The split makes it obvious which side is providing and which is consuming.
What is a type class in Scala?
A trait parameterised by a type, with instances provided as givens — so behaviour is added to a type without modifying it or inheriting from anything. It is how one codebase can define JSON encoding for `Int`, `String` and its own types uniformly.
How do extension methods work?
`extension (x: T) def foo = ...` adds `foo` to every value of type `T`, resolved at compile time with no runtime wrapper. It is how you add a method to a type you do not control, such as `String` or a library's class.