Scala 3's Type System, Part 5: Given, Using, and Type Classes
You need JSON encoding for java.time.Instant. You don’t own Instant, so you can’t make it extend your trait, and threading an encoder through every call site by hand gets old fast. Contextual abstraction is the machinery for this, and Scala 3 rebuilt it from the ground up after a decade of complaints about implicit.
🔗The problem, part one: threading a value by hand
Here’s a config object that every layer of a call stack needs, passed explicitly:
final case class AppConfig(baseUrl: String, timeoutMs: Int, apiKey: String)
final case class LineItem(sku: String, qty: Int)
final case class Order(id: String, items: List[LineItem])
def httpGet(path: String, config: AppConfig): String =
s"GET ${config.baseUrl}$path key=${config.apiKey} timeout=${config.timeoutMs}"
def fetchOrder(id: String, config: AppConfig): Order =
val _ = httpGet(s"/orders/$id", config)
Order(id, Nil)
def fetchItems(order: Order, config: AppConfig): List[LineItem] =
val _ = httpGet(s"/orders/${order.id}/items", config)
Nil
def orderSummary(id: String, config: AppConfig): String =
val order = fetchOrder(id, config)
val items = fetchItems(order, config)
s"${order.id}: ${items.size} items"
def dailyReport(ids: List[String], config: AppConfig): String =
ids.map(id => orderSummary(id, config)).mkString("\n")
Only httpGet actually reads the config. The other four functions carry it purely so it can reach the bottom. Add a tracing span, a database transaction handle, or an ExecutionContext and you do the same mechanical edit again across every signature in the path.
🔗The problem, part two: you don’t own the type
The obvious fix for “behavior that depends on a type” is a trait. That works right up until the type isn’t yours:
// scala-check: expect-error
import java.time.Instant
trait Show:
def show: String
final case class Order(id: String) extends Show:
def show: String = s"Order($id)"
def logAll(xs: List[Show]): Unit = xs.foreach(x => println(x.show))
@main def run(): Unit =
logAll(List[Show](Order("ord_1"), Instant.EPOCH))Found: (java.time.Instant.EPOCH : java.time.Instant)
Required: Show
Instant is final, lives in the JDK, and will never extend your trait. Subtyping requires the type author’s cooperation. You need behavior attached to a type from the outside.
🔗Scala 2 said implicit four times
Scala 2 solved both problems with one keyword, which is exactly the trouble:
| What you wrote | What it meant |
|---|---|
implicit val x: Codec | here is an instance |
(implicit x: Codec) | I require an instance |
implicit def f(a: A): B | silently convert A to B |
implicit class C(a: A) | add extension methods to A |
// scala-check: skip
// Scala 2 — four unrelated features, one keyword
implicit val defaultTimeout: Timeout = Timeout(2.seconds)
implicit def stringToOrderId(s: String): OrderId = OrderId(s)
implicit class RichOrder(o: Order) { def total: Long = ??? }
def send(msg: Msg)(implicit t: Timeout, ec: ExecutionContext): Future[Ack]
Reading unfamiliar code, you had to look at the shape of the definition to work out which of the four things was happening. Conversions were the worst of them: a single implicit def in scope could make an otherwise-illegal expression compile, and the resulting behavior lived in a file nobody was looking at.
Scala 3 keeps the mechanism and splits the syntax by intent.
🔗using: state the requirement once
final case class AppConfig(baseUrl: String, timeoutMs: Int, apiKey: String)
final case class LineItem(sku: String, qty: Int)
final case class Order(id: String, items: List[LineItem])
def httpGet(path: String)(using config: AppConfig): String =
s"GET ${config.baseUrl}$path key=${config.apiKey} timeout=${config.timeoutMs}"
def fetchOrder(id: String)(using AppConfig): Order =
val _ = httpGet(s"/orders/$id")
Order(id, Nil)
def fetchItems(order: Order)(using AppConfig): List[LineItem] =
val _ = httpGet(s"/orders/${order.id}/items")
Nil
def orderSummary(id: String)(using AppConfig): String =
s"${fetchOrder(id).id}: ${fetchItems(fetchOrder(id)).size} items"
def dailyReport(ids: List[String])(using AppConfig): String =
ids.map(orderSummary).mkString("\n")
given prodConfig: AppConfig = AppConfig("https://api.example.com", 2000, "sk_live_x")
@main def run(): Unit =
println(dailyReport(List("ord_1", "ord_2")))
println(dailyReport(List("ord_3"))(using AppConfig("http://localhost:8080", 50, "sk_test")))
Two details worth noting. The middle layers write (using AppConfig) with no parameter name — they only forward the value, so naming it would be dead weight. And the call site can still pass one explicitly with (using ...), which is how you override for a test or a sandbox environment. Scala 2 had no such syntax; you wrote f(x)(config) and hoped the reader knew what the second list was.
🔗given: supply the instance
A given is a value the compiler is allowed to find by type. Combine that with a trait parameterized on a type and you have a type class: a trait describing the behavior, one instance per type, extension methods for ergonomics, and a summoner so callers can get the instance directly.
import java.time.Instant
enum Json:
case JNull
case JBool(value: Boolean)
case JNum(value: Double)
case JStr(value: String)
case JArr(items: List[Json])
case JObj(fields: List[(String, Json)])
trait JsonEncoder[A]:
def encode(a: A): Json
object JsonEncoder:
// the summoner: JsonEncoder[Order] instead of summon[JsonEncoder[Order]]
def apply[A](using e: JsonEncoder[A]): JsonEncoder[A] = e
given string: JsonEncoder[String] = Json.JStr(_)
given long: JsonEncoder[Long] = n => Json.JNum(n.toDouble)
given bool: JsonEncoder[Boolean] = Json.JBool(_)
// an instance for a type we don't own
given instant: JsonEncoder[Instant] = i => Json.JStr(i.toString)
// conditional instances: List[A] is encodable when A is
given option[A](using e: JsonEncoder[A]): JsonEncoder[Option[A]] =
case Some(a) => e.encode(a)
case None => Json.JNull
given list[A](using e: JsonEncoder[A]): JsonEncoder[List[A]] =
as => Json.JArr(as.map(e.encode))
extension [A](a: A)(using e: JsonEncoder[A])
def toJson: Json = e.encode(a)
final case class LineItem(sku: String, qty: Long)
final case class Order(id: String, placedAt: Instant, items: List[LineItem], note: Option[String])
given lineItemEncoder: JsonEncoder[LineItem] = item =>
Json.JObj(List("sku" -> item.sku.toJson, "qty" -> item.qty.toJson))
given orderEncoder: JsonEncoder[Order] = order =>
Json.JObj(List(
"id" -> order.id.toJson,
"placedAt" -> order.placedAt.toJson,
"items" -> order.items.toJson,
"note" -> order.note.toJson
))
def render(json: Json): String = json match
case Json.JNull => "null"
case Json.JBool(b) => b.toString
case Json.JNum(n) => if n.isWhole then n.toLong.toString else n.toString
case Json.JStr(s) => "\"" + s + "\""
case Json.JArr(items) => items.map(render).mkString("[", ",", "]")
case Json.JObj(fs) => fs.map((k, v) => "\"" + k + "\":" + render(v)).mkString("{", ",", "}")
@main def run(): Unit =
val order = Order("ord_991", Instant.EPOCH, List(LineItem("SKU-1", 2L)), None)
println(render(order.toJson))
The given instant line is the whole point: Instant gains JSON encoding without the JDK knowing you exist. The given list[A](using e: JsonEncoder[A]) line is the other half — a derived instance. Asking for JsonEncoder[List[Option[String]]] makes the compiler assemble three instances that nobody wrote down.
Note that given instances placed in the companion object of the type class are found automatically. Instances defined anywhere else have to be imported.
🔗Context bounds and importing givens
[A: JsonEncoder] is shorthand for [A](using JsonEncoder[A]). Use it when you don’t need to name the instance:
trait Show[A]:
def show(a: A): String
object CompactShows:
given showInt: Show[Int] = _.toString
given showBool: Show[Boolean] = b => if b then "1" else "0"
object VerboseShows:
given showInt: Show[Int] = n => s"Int($n)"
given showBool: Show[Boolean] = b => if b then "true" else "false"
def render[A: Show](a: A): String = summon[Show[A]].show(a)
def renderAll[A: Show](as: List[A]): String = as.map(render).mkString(", ")
// all givens from a scope
def compactLine: String =
import CompactShows.given
render(42) + render(true)
// only the givens of a specific type
def mixedLine: String =
import CompactShows.{given Show[Int]}
import VerboseShows.{given Show[Boolean]}
render(42) + render(true)
// or by name, like any other member
def byName: String =
import VerboseShows.showInt
render(1)
import CompactShows.* deliberately does not bring in givens — you have to write .given or name them. That is a direct response to Scala 2, where a wildcard import could quietly change which instance you got.
🔗Gotchas
Ambiguity is a compile error, and the message tells you the names. Two instances of the same type in the same scope is not “last one wins”:
// scala-check: expect-error
trait Show[A]:
def show(a: A): String
given verbose: Show[Boolean] = b => if b then "yes" else "no"
given terse: Show[Boolean] = b => if b then "y" else "n"
val rendered: String = summon[Show[Boolean]].show(true)Ambiguous given instances: both given instance verbose and given
instance terse match type Show[Boolean] of parameter x of method
summon in object Predef
Which is the argument for naming your givens. given Show[Boolean] = ... is legal and the compiler invents a name like given_Show_Boolean, but that name is what shows up in ambiguity errors, in stack traces, and in -Xprint:typer output. Anonymous givens are fine for a handful of primitives in a companion object; for anything you might have to debug, name them.
Given resolution prefers the more specific instance, and specificity rules for overlapping conditional instances are subtle. If you find yourself reasoning about priority, you probably want to move one of the instances to a lower-priority trait that the companion extends, rather than relying on the compiler agreeing with your intuition.
Implicit conversions are now opt-in at the use site. You define one as a given Conversion[A, B], but callers must import the language feature before it applies:
opaque type OrderId = String
object OrderId:
def apply(raw: String): OrderId = raw
def unwrap(id: OrderId): String = id
given Conversion[String, OrderId] = OrderId.apply
def lookup(id: OrderId): String = s"looking up ${OrderId.unwrap(id)}"
def explicit: String = lookup(OrderId("ord_991"))
def viaConversion: String =
import scala.language.implicitConversions
lookup("ord_991")
Without that import you get a feature warning at the call site, not a silent success. The action-at-a-distance is still possible, but now it is visible in the file where it happens. (opaque type is Part 4.)
Writing instances by hand does not scale. The Order and LineItem encoders above are pure mechanical transcription of the field lists, and they rot the moment somebody adds a field.
🔗Next up
That last gotcha is the whole subject of Part 6: Type Class Derivation, where scala.deriving.Mirror and a derives clause replace the hand-written encoders with a single inline def — and introduce a fresh set of tradeoffs around compile time.