Skip to main content
Scala advanced Lesson 9 of 10

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 boundA 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:

AnnotationMeaningFor 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]invariantdo 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.

Frequently Asked Questions

What does +A mean in a Scala generic?
Covariance: if `Dog` is a `Animal` then `List[Dog]` is a `List[Animal]`. It is safe for containers that only produce values. Use `-A` (contravariance) for types that only consume values, and no annotation when a type both produces and consumes.
When should I use an opaque type?
When you want a distinct type for something already represented by a primitive — an order id that must not be confused with a customer id. Opaque types are erased at compile time, so you get the type safety of a wrapper class with no allocation.
What are union types in Scala 3?
`A | B` accepts a value of either type, without needing a common supertype or a wrapper. They are useful at API boundaries and for narrow error types; a sealed hierarchy is still better when the variants carry structure you match on.
What is the difference between a type bound and a context bound?
A type bound like `[A <: Ordered[A]]` constrains what `A` may be through subtyping. A context bound like `[A: Ordering]` requires a given instance to exist, which works for types you do not own and is the type-class approach.