Generics, Variance, and Opaque Types
Write code that works for any type, get variance right so subtyping flows the way you expect, and make wrappers that cost nothing at runtime.
Generics let one implementation serve every type. Variance decides how those generic types relate when their parameters do. Both are usually invisible — until a signature will not compile and the error mentions covariant positions.
Generic methods and classes
def firstOr[A](xs: List[A], default: A): A = xs.headOption.getOrElse(default)
case class Box[A](value: A):
def map[B](f: A => B): Box[B] = Box(f(value))
@main def run(): Unit =
println(firstOr(List(1, 2, 3), 0))
println(firstOr(List.empty[String], "none"))
val b = Box(21)
println(b.map(_ * 2))
println(b.map(_.toString + "!"))
1
none
Box(42)
Box(21!)
A is inferred from the arguments. Box[A].map[B] introduces a second parameter for the
result, which is why map can change the contained type.
Bounds
case class Order(id: Int, amount: BigDecimal)
def largest[A](xs: List[A])(using ord: Ordering[A]): Option[A] =
if xs.isEmpty then None else Some(xs.max)
def largestBy[A: Ordering](xs: List[A]): Option[A] = largest(xs)
trait HasAmount:
def amount: BigDecimal
def total[A <: HasAmount](xs: List[A]): BigDecimal = xs.map(_.amount).sum
case class Invoice(id: Int, amount: BigDecimal) extends HasAmount
@main def run(): Unit =
println(largestBy(List(3, 1, 4, 1, 5)))
println(largestBy(List("b", "a", "c")))
println(largestBy(List.empty[Int]))
println(total(List(Invoice(1, BigDecimal("25.50")), Invoice(2, BigDecimal("12.00")))))
Some(5)
Some(c)
None
37.50
Two different constraints. A <: HasAmount is an upper bound — A must be a subtype, so
this only works for types you can make extend the trait. [A: Ordering] is a context
bound — it requires a given Ordering[A] to exist, which works for Int and String
without either of them knowing about your code. The context bound is the more flexible tool,
and the reason the standard library uses it for sorting.
Ordering for a type of your own is one given:
given Ordering[Order] = Ordering.by(_.amount)
@main def run(): Unit =
println(largestBy(List(Order(1, BigDecimal("25.50")), Order(2, BigDecimal("40.00")))))
Some(Order(2,40.00))
Variance
class Animal(val name: String)
class Dog(name: String) extends Animal(name)
class InvariantBox[A](val value: A)
class CovariantBox[+A](val value: A)
@main def run(): Unit =
val dogs: List[Dog] = List(Dog("Rex"))
val animals: List[Animal] = dogs // List is covariant
println(animals.map(_.name))
val covariant: CovariantBox[Animal] = CovariantBox(Dog("Rex"))
println(covariant.value.name)
val invariant: InvariantBox[Animal] = InvariantBox(Dog("Rex"))
println(invariant.value.name)
List(Rex)
Rex
Rex
That last one compiles because the value is a Dog and the type argument is written as
Animal. The difference appears when the container itself is already typed:
@main def run(): Unit =
val boxOfDogs = InvariantBox(Dog("Rex"))
val asAnimals: InvariantBox[Animal] = boxOfDogs
-- [E007] Type Mismatch Error: variance.scala:18:41 --------------------
18 | val asAnimals: InvariantBox[Animal] = boxOfDogs
| ^^^^^^^^^
| Found: (boxOfDogs : InvariantBox[Dog])
| Required: InvariantBox[Animal]
1 error found
With +A it compiles. The rule:
| Annotation | Meaning | For types that |
|---|---|---|
[+A] | covariant: Box[Dog] is a Box[Animal] | only produce A |
[-A] | contravariant: Sink[Animal] is a Sink[Dog] | only consume A |
[A] | invariant | do both |
Contravariance is the one that looks backwards until you see it:
trait Printer[-A]:
def print(a: A): String
val animalPrinter: Printer[Animal] = a => s"animal ${a.name}"
val dogPrinter: Printer[Dog] = animalPrinter // Printer[Animal] works wherever Printer[Dog] is needed
@main def run(): Unit =
println(dogPrinter.print(Dog("Rex")))
animal Rex
Something that can print any Animal can certainly print a Dog, so Printer[Animal] is
usable as a Printer[Dog]. That is contravariance, and it is why Function1[-T, +R] is
contravariant in its argument and covariant in its result.
The compiler enforces the positions:
class Bad[+A]:
def add(a: A): Unit = ()
-- Error: variance.scala:2:14 ------------------------------------------
2 | def add(a: A): Unit = ()
| ^^^^
|covariant type A occurs in contravariant position in type A of parameter a
A covariant container cannot accept an A as a parameter — otherwise you could put a Cat
into a Box[Dog] viewed as a Box[Animal]. The escape is a lower bound:
class Good[+A](items: List[A]):
def add[B >: A](b: B): Good[B] = Good(b :: items)
@main def run(): Unit =
val dogs = Good(List(Dog("Rex")))
val mixed = dogs.add(new Animal("Generic"))
println(mixed)
Good(List(Animal@4a87761d, Dog@6d5380c2))
Adding an Animal to a Good[Dog] widens the result to Good[Animal]. This is exactly how
List.:: and Option.getOrElse are declared.
Opaque types
opaque type OrderId = Int
opaque type CustomerId = Int
object OrderId:
def apply(i: Int): OrderId = i
extension (id: OrderId) def value: Int = id
object CustomerId:
def apply(i: Int): CustomerId = i
extension (id: CustomerId) def value: Int = id
def fetchOrder(id: OrderId): String = s"order ${id.value}"
@main def run(): Unit =
val orderId = OrderId(1001)
val customerId = CustomerId(1)
println(fetchOrder(orderId))
println(fetchOrder(customerId))
-- [E007] Type Mismatch Error: ids.scala:20:20 -------------------------
20 | println(fetchOrder(customerId))
| ^^^^^^^^^^
| Found: (customerId : CustomerId)
| Required: OrderId
1 error found
Both are Int at runtime — no wrapper object, no allocation — but the compiler treats them
as unrelated outside the object that defines them. Passing a customer id where an order id
belongs stops being possible, and the cost is zero.
@main def run(): Unit =
println(fetchOrder(1001))
|Found: (1001 : Int)
|Required: OrderId
Even a bare Int is rejected, which is the point — the only way in is through
OrderId.apply, so validation can live there.
Union and intersection types
type Reason = String
def describe(x: Int | String | Boolean): String = x match
case i: Int => s"number $i"
case s: String => s"text '$s'"
case b: Boolean => s"flag $b"
trait Named: def name: String
trait Aged: def age: Int
def label(p: Named & Aged): String = s"${p.name} (${p.age})"
case class Person(name: String, age: Int) extends Named, Aged
@main def run(): Unit =
println(describe(42))
println(describe("hello"))
println(describe(true))
println(label(Person("Ada", 36)))
number 42
text 'hello'
flag true
Ada (36)
A | B needs no common supertype and no wrapper, and the match is exhaustive — remove the
Boolean case and the compiler warns. A & B requires both. Unions suit narrow API
boundaries; a sealed hierarchy is still better when the variants carry structure worth naming.
Where inference stops
@main def run(): Unit =
val xs = List(1, 2, 3)
val folded = xs.foldLeft(Map.empty[String, Int])((acc, n) => acc.updated(n.toString, n))
println(folded)
val broken = xs.foldLeft(Map.empty)((acc, n) => acc.updated(n.toString, n))
-- [E008] Not Found Error: inference.scala:6:44 ------------------------
6 | val broken = xs.foldLeft(Map.empty)((acc, n) => acc.updated(n.toString, n))
| ^^^^^^^
|value updated is not a member of Map[Nothing, Nothing]
Map.empty without type arguments infers Map[Nothing, Nothing]. Inference flows left to
right through parameter lists, so the accumulator’s type must be stated where it is
introduced. The same applies to a var initialised to None — write
var x: Option[Order] = None, or its type is Option[Nothing] forever.
Practice
1. Write a covariant container and assign it to a supertype.
class Box[+A](val value: A)
val dogBox: Box[Dog] = Box(Dog("Rex"))
val animalBox: Box[Animal] = dogBox
println(animalBox.value.name)
Rex
Change +A to A and the assignment fails to compile. Covariance is what makes
List[Dog] usable as List[Animal] — which you rely on constantly without noticing.
2. Add a method taking A to a covariant class.
covariant type A occurs in contravariant position in type A of parameter a
The fix is def add[B >: A](b: B): Box[B], which widens the result instead of breaking type
safety. This exact signature appears throughout the collections library once you know to look
for it.
3. Define two opaque types over Int and mix them up.
Found: (customerId : CustomerId)
Required: OrderId
Two ids that are both Int at runtime and cannot be confused at compile time. In a codebase
where most function arguments are ids, this removes a genuinely common bug for no runtime
cost.
4. Use a context bound to sort a type you do not own.
given Ordering[Order] = Ordering.by(_.amount)
println(List(Order(1, BigDecimal("25.50")), Order(2, BigDecimal("40.00"))).sorted)
List(Order(1,25.50), Order(2,40.00))
Order did not extend anything. A context bound asks for a capability rather than demanding
inheritance, which is why it works for library types you cannot modify.
Next: sbt, packaging, and shipping a JAR — including one that runs on Spark.