Design with Types — Practical Scala 3 from Basics to Type-Level Programming
This tutorial is designed to be worked through with
scala-cli, having a conversation with the compiler at every step. The most important part of each step: compile first, read the error, then understand.
Who This Is For
You use type parameters — List[Int], Option[String], Map[K, V] — every day.
But [+A], [B >: A], match types, =:=? Those feel like a different language.
You’ve been told they’re “advanced” and moved on. That’s completely reasonable.
This tutorial argues they’re not advanced — they click once you see the problems they solve. The concepts build on each other in a straight line, and every one of them addresses a concrete problem you’ve already encountered.
Why This Matters
Here’s the fundamental tension: on the JVM, generics are erased.
At runtime, List[Int] and List[String] are both just List.
The JVM doesn’t know what types your collections hold.
That means compile time is your only chance to catch type errors. Everything in this tutorial — variance, opaque types, typeclasses, match types — is about giving the compiler more information so it can catch more mistakes before erasure throws that information away.
This matters even more now. In an era where LLMs write more code than humans type, the type system becomes the critical layer between human intent and generated code. A rich type system with explicit compiler messages catches what LLMs silently drop — an implicit convention, a missing annotation, a subtle variance requirement. The compiler doesn’t forget, and it doesn’t hallucinate. It’s the one reviewer that checks every line, every time.
Structure
Part 1: The Type System (Steps 0–5) covers how to use Scala’s type system effectively — type parameters, variance, bounds, opaque types, type members, and typeclasses. This is what most production Scala code needs.
Part 2: Type-Level Programming (Steps 6–8) covers types that compute — match types, compile-time validation, and type equality proofs. These techniques show up in library design and domain modeling where the compiler can catch entire classes of bugs that the type system alone can’t.
Setup
# Install scala-cli (if you haven't already)
curl -sSLf https://scala-cli.virtuslab.org/get | sh
# Verify
scala-cli version
# Scala 3.x is required
Using the Example Files
Every code example in this book is available as a ready-to-run file in the examples/ directory. Clone the repository and run any example:
git clone https://github.com/hanishi/scala3-design-with-types.git
cd scala3-design-with-types
# Run a specific example
scala-cli run examples/step0/step0.scala
# Or cd into a step directory
cd examples/step2
scala-cli run step2c.scala
Each file is self-contained — just pick the step you’re reading and run the corresponding file.
Important: Always run a single file at a time — do not compile an entire directory (e.g.,
scala-cli compile examples/step2/). Files within a step intentionally redefine the same classes (Product,Book,Box, etc.) with different signatures to show a progression. Compiling them together will produce duplicate-definition errors.
Part 1: The Type System
Part 1 (Steps 0–5) covers how to use Scala’s type system effectively — type parameters, variance, bounds, opaque types, type members, and typeclasses. This is what most production Scala code needs.
Step 0: Life Without Types
Let’s feel what happens when type information is lost.
0-1. The Anything-Goes List
// step0.scala
@main def step0(): Unit =
// In Scala, you can't omit type parameters.
// But you can use Any to make a list that holds everything.
val xs: List[Any] = List(100, "hello", true, 3.14)
// What do you get when you pull something out?
val first: Any = xs.head // It's 100... but the type is Any
// Try to do arithmetic — compile error
// val doubled = first * 2
// [error] value * is not a member of Any
// [error] val doubled = first * 2
// [error] ^^^^^^^
println(first) // prints 100
println(first.getClass) // class java.lang.Integer
Try it:
> scala-cli run step0.scala
Uncomment first * 2 and compile. Read the error.
The compiler says Any has no * method.
The insight: The value is 100 at runtime, but the compiler doesn’t know that. The moment type information is lost, the compiler can no longer protect you.
Step 1: Type Parameters Are Contracts
1-1. Build Your Own Box
// step1a.scala
// A box that holds anything (no type parameter)
class AnyBox(val value: Any)
// A box with a type parameter
class Box[A](val value: A)
@main def step1(): Unit =
// AnyBox: easy to put things in
val anyBox = AnyBox(42)
// val n: Int = anyBox.value // Compile error! Any is not Int
val n: Int = anyBox.value.asInstanceOf[Int] // Dangerous cast
// Box[A]: the type is preserved
val intBox = Box(42) // Inferred as Box[Int]
val m: Int = intBox.value // Comes out as Int — no cast needed
// Wrong type? Compile error.
// val s: String = intBox.value // error: Found Int, Required String
println(s"AnyBox: $n, Box[Int]: $m")
Try it:
> scala-cli run step1a.scala
Then uncomment val s: String = intBox.value.
The insight: The A in Box[A] doesn’t “remember” what you put in.
The compiler commits to A = Int at construction time. That’s a contract.
When you take the value out, it’s guaranteed to be Int.
1-2. Type Parameters in Functions — Contracts Propagate
// step1b.scala
def first[A](xs: List[A]): A = xs.head
@main def step1b(): Unit =
val names = List("Scala", "Rust", "Go")
val top: String = first(names) // Compiler resolves A = String
println(top)
val nums = List(1, 2, 3)
val one: Int = first(nums) // Compiler resolves A = Int
println(one)
// What about this?
val mixed = List(1, "two", 3.0)
val what = first(mixed) // What is A here?
println(what.getClass)
Inspect the types:
scala-cli run step1b.scala
# To see the inferred type (clean first to bypass compilation cache):
scala-cli clean step1b.scala && scala-cli compile step1b.scala -O -Xprint:typer 2>&1 | grep "mixed"
mixed is inferred as List[Int | String | Double] — a union type. This is a Scala 3 thing.
Notice that you never wrote List[Int | String | Double] — the compiler inferred it.
Throughout this book, we rely on the compiler’s ability to figure out type parameters
from context. How type inference works internally is a topic of its own; here we just
trust that it does, and focus on what the types mean.
Step 2: Variance and Bounds
Variance is not a set of rules to memorize. It’s something you experience by asking “why won’t this compile?” alongside the compiler. The starting point is invariant (no compatibility). The fact that this is the default matters.
2-1. Invariant — The Default Wall
// step2a.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// Define our own Box. The type parameter is [A] — invariant.
class Box[A](val value: A)
@main def step2a(): Unit =
val bookBox: Box[Book] = Box(Book("Scala in Depth", 45.0))
// Book is a subtype of Item. So is Box[Book] a subtype of Box[Item]?
// val itemBox: Box[Item] = bookBox
// [error] Found: (bookBox : Box[Book])
// [error] Required: Box[Item]
// [error] val itemBox: Box[Item] = bookBox
// [error] ^^^^^^^
println("Box[Book] is NOT Box[Item]")
Uncomment and compile.
The compiler treats Box[Book] and Box[Item] as completely different types.
Just because Book <: Item doesn’t mean Box[Book] <: Box[Item].
Notation:
<:means “is a subtype of” and>:means “is a supertype of.”Book <: Itemreads as “Book is a subtype of Item” — i.e.,Book extends Item. You’ll see these symbols used both in prose and in Scala code (type bounds) throughout this chapter.
The compiler’s default: “Show me it’s safe, and I’ll allow it.”
2-2. Why Invariant Is the Default — Break It to Understand
// step2b.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// What if Box[Book] could be used as Box[Item]?
// Note: var means the value can be reassigned — this box is mutable (read AND write).
class Box[A](var value: A)
@main def step2b(): Unit =
val bookBox: Box[Book] = Box(Book("Scala in Depth", 45.0))
// Try uncommenting the next line and compile:
// val itemBox: Box[Item] = bookBox
// [error] Found: (bookBox : Box[Book])
// [error] Required: Box[Item]
// [error] val itemBox: Box[Item] = bookBox
// [error] ^^^^^^^
//
// The compiler rejects this. But imagine if it didn't — what could go wrong?
// itemBox.value = DVD("The Matrix", 19.99) // Item allows DVD...
// val book: Book = bookBox.value // ...but now a DVD comes out as a Book!
println("The compiler prevents this — a mutable box must be invariant")
This is a thought experiment, but in Java it actually happens:
// Step2bJava.java — Java arrays are covariant, but generics are invariant
import java.util.List;
import java.util.ArrayList;
class Item {
String name;
Item(String name) { this.name = name; }
}
class Book extends Item {
Book(String name) { super(name); }
}
class DVD extends Item {
DVD(String name) { super(name); }
}
public class Step2bJava {
public static void main(String[] args) {
// --- Arrays: covariant (unsafe) ---
Book[] books = { new Book("Scala") };
Item[] items = books; // Compiles — Java arrays are covariant
items[0] = new DVD("The Matrix"); // ArrayStoreException at runtime!
// --- Generics: invariant (safe) ---
// List<Book> bookList = new ArrayList<>();
// List<Item> itemList = bookList; // Compile error! Java generics are invariant.
// Java learned from the array mistake — generics don't allow this.
}
}
Try it (requires javac and java):
javac Step2bJava.java && java Step2bJava
It compiles without warnings — and crashes at runtime with ArrayStoreException.
Java generics are invariant — List<Book> is not List<Item>, and the compiler
rejects the assignment. Uncomment the generics section in the example to see this.
Arrays, designed earlier, are covariant — and that’s where the runtime crash comes from.
Scala’s invariant default applies to everything — arrays included — and eliminates this class of bugs at compile time.
2-3. Making It Covariant — What +A Means
// step2c.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// Same Box, but now with +A — covariant.
class Box[+A](val value: A)
@main def step2c(): Unit =
val bookBox: Box[Book] = Box(Book("Scala in Depth", 45.0))
// In 2-1, Box[A] rejected this. The + is what makes the difference.
// Book is an Item, so Box[Book] can now be used as Box[Item].
val itemBox: Box[Item] = bookBox
println(itemBox.value) // Book(Scala in Depth, $45.0)
With +A, subtyping lifts through the container:
if Book <: Item, then Box[Book] <: Box[Item].
This Box only holds a value. What if you also want to check what’s inside, or change it?
2-4. Testing the Compiler’s Checks
Uncomment each block one at a time.
// step2d.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// Uncomment and compile each block one at a time:
// 1. Try +A with a var — var lets you write a new value in, violating +A.
// class Box[+A](var value: A)
// [error] covariant type A occurs in invariant position in type A of variable value
// [error] class Box[+A](var value: A)
// [error] ^^^^^^^^^^^^
// 2. OK, only a val. But what about checking what's inside?
class Box[+A](val value: A):
def contains(item: A): Boolean = value == item
// [error] covariant type A occurs in contravariant position in type A of parameter item
// [error] def contains(item: A): Boolean = value == item
// [error] ^^^^^^^
//
// With just [A] (invariant), contains works fine.
// It's the + that prevents passing A in.
@main def step2d(): Unit =
println("Uncomment each block and read the error messages")
With an invariant Box[A], both var value: A and contains(item: A) are safe.
This is because Box[Book] is not a Box[Item].
The type cannot be widened through subtyping, so no incompatible value can be introduced.
Once we declare Box[+A], Scala permits Box[Book] to be viewed as Box[Item].
From that broader perspective:
var value: Awould allow assigning aDVDinto what is actually aBox[Book]contains(DVD(...))would appear valid — since aDVDis anItem
However, the underlying object remains a Box[Book]. To preserve type safety, Scala rejects both cases:
var value: A→ “invariant position” — avaris both read and writecontains(item: A)→ “contravariant position” — a parameter consumes values
A covariant type parameter (+A) may appear only in output positions, such as:
val value: A- a method return type
Why reject
contains, even though it seems harmless? Scala enforces variance rules structurally — it does not analyze method implementations. Whilecontainswould be harmless in this case, other members — likevar value: A— would be unsound. The rule is uniform: a covariant type parameter (+A) cannot appear in input positions.
So how can we retain covariance without giving up useful inputs? That’s where type bounds come in.
2-5. Lower Bounds — Escaping the Variance Constraint
In 2-4, we saw that +A rejects A in input positions.
B >: A means “B is a supertype of A” — B must be A or something above it.
Think of the compiler saying:
“You declared +A, so I can’t let you take an A as input — that would break
covariance. But give me a B that’s a supertype of A, and I’ll find the narrowest
type that fits both what’s already here and what you’re adding.
If it already fits, B is just A and nothing changes. Otherwise the cart
widens — but it never narrows.”
// step2e.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// An immutable shopping cart. +A makes it covariant.
class Cart[+A](private val items: List[A] = Nil):
// def add(item: A) won't compile with +A. Try it!
// def add[B >: A](item: B) compiles — B must be A or wider,
// so the cart can only stay the same or widen, never narrow:
// add(Book) to Cart[Book] → still a cart of Books → Cart[Book]
// add(DVD) to Cart[Book] → no longer just Books → Cart[Item]
def add[B >: A](item: B): Cart[B] = Cart(item :: items)
// A is whatever this cart holds — Book for Cart[Book], Item for Cart[Item].
def total(f: A => Double): Double = items.map(f).sum
override def toString = s"Cart(${items.mkString(", ")})"
@main def step2e(): Unit =
// Cart() is Cart[Nothing] — an empty cart with no type yet.
// add(Book(...)) widens Nothing to Book → Cart[Book].
val bookCart: Cart[Book] = Cart().add(Book("Scala in Depth", 45.0))
println(bookCart) // Cart(Book(Scala in Depth, $45.0))
// Add another Book → stays Cart[Book]
val bookCart2: Cart[Book] = bookCart.add(Book("FP in Scala", 35.0))
println(bookCart2) // Cart(Book(FP in Scala, $35.0), Book(Scala in Depth, $45.0))
// Add DVD → widens to Cart[Item] (common parent of Book and DVD)
val mixedCart: Cart[Item] = bookCart.add(DVD("The Matrix", 19.99))
println(mixedCart) // Cart(DVD(The Matrix, $19.99), Book(Scala in Depth, $45.0))
// The type widened: mixedCart is Cart[Item], not Cart[Book].
// A is now Item, so total's signature became:
// total(f: Item => Double)
// Item has price, so _.price just works:
val subtotal = mixedCart.total(_.price)
val discounted = mixedCart.total(_.price * 0.95)
println(f"Subtotal: $$$subtotal%.2f") // Subtotal: $64.99
println(f"With 5%% off: $$$discounted%.2f") // With 5% off: $61.74
This is exactly what List[+A]’s prepended does:
// Simplified definition of List
sealed abstract class List[+A]:
def prepended[B >: A](elem: B): List[B]
Prepend a DVD to a List[Book] and you get List[Item]. The types don’t lie.
Where do lower bounds show up?
You might wonder: “Are lower bounds basically used in the same kinds of situations?”
Yes — they cluster around a small set of roles:
Widening containers — inserting into covariant collections:
val books: List[Book] = List(Book("Scala", 45.0))
val items: List[Item] = books :+ DVD("The Matrix", 19.99)
// List[Book] widened to List[Item]
Fallback values — Option.getOrElse[B >: A](default: => B): B:
val maybeBook: Option[Book] = None
val item: Item = maybeBook.getOrElse(DVD("The Matrix", 19.99))
// Option[Book] → no Book present → falls back to DVD → result is Item
Combining/merging — Either’s orElse widens the Right type:
val result: Either[String, Book] = Left("not found")
val recovered: Either[String, Item] = result.orElse(Right(DVD("The Matrix", 19.99)))
// Either[String, Book] → recovery with DVD → Either[String, Item]
In each case, the pattern is the same: [B >: A] lets the type widen to accommodate
a value that doesn’t fit A exactly. The compiler picks the narrowest type that works —
Item, not Any. This is called the LUB (Least Upper Bound).
In Step 9, we’ll see what happens when there is no named common
supertype — Scala 3 uses union types instead.
You won’t write [B >: A] in everyday application code — but those foundational APIs
genuinely depend on it. Without lower bounds, covariant containers simply couldn’t
have “add” methods.
Why is widening safe? Think of it as the compiler telling you:
“I’ll allow this — every element has at least Item’s methods, so your operations
are safe. But I can no longer promise what’s specifically inside. You asked me to
widen to Item, so Item is all I’ll guarantee.”
You gain the ability to mix types; you lose the right to assume a specific one.
Use [B >: A] when that trade-off is real, not as a precaution.
2-6. Upper Bounds — Requiring Minimum Behavior
If lower bounds widen, upper bounds narrow.
// step2f.scala
trait Item:
def name: String
def price: Double
trait Shippable extends Item:
def weight: Double
def shippingCost: Double = weight * 0.5
case class Book(name: String, price: Double, weight: Double) extends Shippable
case class DVD(name: String, price: Double, weight: Double) extends Shippable
case class DigitalGiftCertificate(name: String, price: Double) extends Item // Not shippable
// Only Shippable items can be sorted by shipping cost
def sortByShipping[A <: Shippable](items: List[A]): List[A] =
items.sortBy(_.shippingCost)
// Combining covariance with an upper bound:
// +A means Shipment[Book] <: Shipment[Shippable]
// <: Shippable means every item is guaranteed to have shippingCost
class Shipment[+A <: Shippable](private val items: List[A]):
def totalShippingCost: Double = items.map(_.shippingCost).sum
def sortByShipping: List[A] = items.sortBy(_.shippingCost)
// +A forbids A in input position, so we use [B >: A] — same escape hatch as List's :+
def add[B >: A <: Shippable](item: B): Shipment[B] = Shipment(items :+ item)
@main def step2f(): Unit =
// Mix of Book and DVD → List[Shippable], A = Shippable
val items = List(
Book("Scala in Depth", 45.0, 0.8),
DVD("The Matrix", 19.99, 0.2),
)
sortByShipping(items).foreach(p => println(s"${p.name}: shipping=${p.shippingCost}"))
// Try sorting DigitalGiftCertificate?
//val cards = List(DigitalGiftCertificate("Amazon $50", 50.0))
//sortByShipping(cards)
// [error] Found: (cards : List[DigitalGiftCertificate])
// [error] Required: List[Shippable]
// [error] sortByShipping(cards)
// [error] ^^^^^
// Upper bound + covariance on a class
val shipment = Shipment(List(Book("Scala in Depth", 45.0, 0.8)))
println(s"Total shipping: ${shipment.totalShippingCost}")
// add uses [B >: A <: Shippable] — widens from Shipment[Book] to Shipment[Shippable]
val wider = shipment.add(DVD("The Matrix", 19.99, 0.2))
println(s"Total shipping after add: ${wider.totalShippingCost}")
// Try adding a DigitalGiftCertificate to a Shipment?
//val shipmentFails = Shipment(List(DigitalGiftCertificate("Netflix", 20.00)))
// [error] Found: DigitalGiftCertificate
// [error] Required: Shippable
// [error] val shipmentFails = Shipment(List(DigitalGiftCertificate("Netflix", 20.00)))
// [error] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Where do upper bounds show up?
Like lower bounds, they cluster around a few roles:
Requiring specific behavior — only accept types with certain methods:
def sortByShipping[A <: Shippable](items: List[A]): List[A] =
items.sortBy(_.shippingCost)
// Only works for Shippable items — DigitalGiftCertificate rejected at compile time
[A <: Shippable] tells the compiler: “only accept types that extend Shippable.”
Inside the function, you can call _.shippingCost because the compiler knows every A
has that method. DigitalGiftCertificate extends Item but not Shippable — so the
compiler rejects it before the code even runs.
Constraining type parameters on classes — ensuring a container only holds suitable types:
class Shipment[+A <: Shippable](items: List[A]):
def totalShippingCost: Double = items.map(_.shippingCost).sum
def add[B >: A <: Shippable](item: B): Shipment[B] = Shipment(items :+ item)
// Can't create Shipment[DigitalGiftCertificate] — not Shippable
The + is the same covariance you saw in section 2-4 — it just works alongside
the bound. <: Shippable restricts which types are allowed, + makes
Shipment[Book] <: Shipment[Shippable].
The add method uses [B >: A <: Shippable] — the same [B >: A] escape hatch
from section 2-5, combined with <: Shippable to stay within the class’s bound.
Adding a DVD to a Shipment[Book] widens it to Shipment[Shippable].
Why two bounds?
You might expect
Bto inherit the<: Shippablebound from the class — after all,Ais already constrained. ButBis its own type parameter, introduced on the method. It only knows what you tell it.And
B >: Apoints up the hierarchy. In Scala, every type hasAnyRefandAnyabove it, so without a capBcould widen all the way:Book → Shippable → Item → AnyRef → Any. The<: Shippablesays “stop here.” Without it,Shipment[B]wouldn’t compile —Shipmentrequires its type parameter to be<: Shippable.
Preserving type information — keeping the specific subtype through a computation:
def cheapest[A <: Item](items: List[A]): A =
items.minBy(_.price)
val books: List[Book] = List(Book("Scala", 45.0), Book("FP", 35.0))
val b: Book = cheapest(books) // returns Book, not Item
val dvds: List[DVD] = List(DVD("The Matrix", 19.99), DVD("Inception", 24.99))
val d: DVD = cheapest(dvds) // returns DVD, not Item
val cards: List[DigitalGiftCertificate] = List(DigitalGiftCertificate("$50", 50.0))
val c: DigitalGiftCertificate = cheapest(cards) // returns DigitalGiftCertificate, not Item
Without the bound, you’d have to write def cheapest(items: List[Item]): Item — and every
call would return Item, even when the list contains Book or DVD. You’d preserve
the behavior, but lose the specific return type.
You also can’t write def cheapest[A](items: List[A]): A, because minBy(_.price)
requires that each element has a .price member. With no bound, the compiler has no
reason to believe that about A.
The upper bound gives you both: access to Item’s API and the precise element type
back (Book in, Book out; DVD in, DVD out). In other words, <: Item is not
about restricting callers — it’s about telling the compiler what operations are valid
while still keeping the result type maximally specific.
Coming from Java?
Java generics are invariant by default. To accept List<Book> where
List<Item> is expected, you must write ? extends at every call site:
// Step2kCov.java — Java's use-site covariance: ? extends
import java.util.List;
class Item {
String name;
double price;
Item(String name, double price) { this.name = name; this.price = price; }
public String toString() { return name + " ($" + price + ")"; }
}
class Book extends Item {
Book(String name, double price, String isbn) { super(name, price); }
}
class DVD extends Item {
DVD(String name, double price) { super(name, price); }
}
public class Step2kCov {
// In Scala: def cheapest[A <: Item](items: List[A]): A
// List[+A] is covariant — List[Book] IS a List[Item].
//
// In Java: List<Book> is NOT List<Item> — generics are invariant.
// To accept a List of any Item subtype, you write ? extends EVERY TIME:
static Item cheapest(List<? extends Item> items) {
Item min = items.get(0);
for (Item item : items) {
if (item.price < min.price) min = item;
}
return min;
// items.add(new DVD("X", 1.0)); // Compile error! Can't add to ? extends
}
public static void main(String[] args) {
List<Book> books = List.of(
new Book("Scala", 45.0, "978-1"),
new Book("FP", 35.0, "978-2")
);
System.out.println("Cheapest book: " + cheapest(books));
List<DVD> dvds = List.of(
new DVD("The Matrix", 19.99),
new DVD("Inception", 24.99)
);
System.out.println("Cheapest DVD: " + cheapest(dvds));
// Without ? extends, this would NOT compile:
// static Item cheapest(List<Item> items) — only accepts List<Item>, not List<Book>
}
}
Try it: javac Step2kCov.java && java Step2kCov
This is use-site variance — if you forget ? extends, the code silently
stops accepting subtypes. Every method that should be flexible needs the
annotation, and nothing warns you when you leave it out.
Scala’s List[+A] declares covariance once at the class definition —
cheapest(books) just works everywhere, and you can’t forget.
Standard library — WeakReference[+T <: AnyRef] only accepts reference types:
import scala.ref.WeakReference
val ref = WeakReference(List(1, 2, 3)) // OK: List is AnyRef
// WeakReference(42) — won't work: Int is not AnyRef
A normal reference (val x = ...) keeps an object alive — the garbage collector won’t
reclaim it as long as someone points to it. A WeakReference is different: it holds
a reference without preventing the GC from reclaiming the object. When memory is tight,
the GC can reclaim it, and the weak reference silently becomes empty.
The most common use case is caches. You want to keep a computed result around for reuse, but not at the cost of running out of memory:
import scala.ref.WeakReference
class Cache[T <: AnyRef](compute: () => T):
private var ref: WeakReference[T] = WeakReference(null)
def get(): T = ref.get match
case Some(value) => value // still in memory, reuse it
case None => // GC reclaimed it, recompute
val value = compute()
ref = WeakReference(value)
value
val users = Cache(() => List("alice", "bob", "charlie"))
users.get() // computes the first time, reuses if GC hasn't reclaimed
This only makes sense for heap-allocated objects (AnyRef). Value types like Int
and Double are stored directly on the stack or inlined into objects — there’s no
heap object for the GC to track or reclaim. The upper bound T <: AnyRef encodes
this JVM reality at the type level: you literally can’t create a weak reference
to something that doesn’t live on the heap.
Don’t worry if this feels advanced — you can safely skip this example and come back to it later. It’s a clever use of upper bounds, not a prerequisite for what follows.
Lower bounds widen — they accept broader types. Upper bounds narrow — they reject types that lack the required behavior.
You now have the full toolkit for the output side: covariance (
+A) for safe widening, lower bounds (>:) to add methods without breaking covariance, and upper bounds (<:) to require specific behavior. Next up: the input side.
2-7. Contravariance — Consumer Compatibility
Finally, -A. Covariance is about the output side — producers.
Contravariance is about the input side — consumers.
// step2g.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name)"
// PriceFormatter[-A]: the - declares it contravariant.
// format CONSUMES an A — it takes it in and reads from it.
trait PriceFormatter[-A]:
def format(item: A): String
// Can format ANY Item — only reads name and price
class ItemFormatter extends PriceFormatter[Item]:
def format(item: Item): String = s"${item.name}: $$${item.price}"
// Can only format Books — reads isbn, which only Book has
class BookFormatter extends PriceFormatter[Book]:
def format(item: Book): String = s"${item.name} (ISBN: ${item.isbn}): $$${item.price}"
@main def step2g(): Unit =
val book = Book("Scala in Depth", 45.0, "978-1617295")
// ItemFormatter consumes a Book by reading name and price.
// It doesn't know about isbn — but that's fine, it never asks for it.
// Safe: Book has everything Item has.
val formatterForBook: PriceFormatter[Book] = ItemFormatter()
println(formatterForBook.format(book))
// prints: Scala in Depth: $45.0 — isbn is there, but the formatter never reads it
// BookFormatter consumes a Book by reading name, price, AND isbn.
val bookFormatter: PriceFormatter[Book] = BookFormatter()
println(bookFormatter.format(book))
// prints: Scala in Depth (ISBN: 978-1617295): $45.0
// Now imagine the reverse: using BookFormatter as PriceFormatter[Item].
// val formatterForItem: PriceFormatter[Item] = bookFormatter // Compile error!
//
// If this compiled, you could write:
// formatterForItem.format(DVD("The Matrix", 19.99))
//
// BookFormatter.format reads item.isbn — but DVD has no isbn.
// The compiler catches this: a Book consumer can't safely consume all Items.
Most tutorials explain contravariance with explicit assignment like
val bookFmt: Formatter[Book] = itemFormatter — like the one in the example
above. That makes the subtyping visible, but you rarely write code like that.
Where you do use contravariance — without realizing it — is every .filter,
.map, .sortBy call where you pass a function on a supertype.
Function1[-A, +B] — the desugared form of A => B — is the most common
contravariant type in the standard library.
A is in the input position —
the function receives it — so it’s contravariant (-A).
B is in the output
position — the function returns it — so it’s covariant (+B).
That’s why Item => Boolean is usable wherever Book => Boolean is expected:
// step2g1.scala — Contravariance in Function1
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
@main def step2g1(): Unit =
val books = List(Book("Scala", 45.0, "978-1"), Book("FP", 35.0, "978-2"))
// These functions accept any Item.
// Item => Boolean is shorthand for Function1[Item, Boolean] (-A = Item, +B = Boolean)
val cheap: Item => Boolean = _.price < 40.0
val priceOf: Item => Double = _.price
// List[Book].filter signature: filter(p: Book => Boolean): List[Book]
// Desugar the arrow: filter(p: Function1[Book, Boolean]): List[Book]
//
// You pass cheap, which is Function1[Item, Boolean] — not Function1[Book, Boolean].
// Why does this compile? Function1[-A, +B] is contravariant in A:
// Function1[Item, Boolean] <: Function1[Book, Boolean]
// An Item function only reads name and price — a Book has both, so it's safe.
println(books.filter(cheap)) // List(Book(FP, $35.0))
println(books.sortBy(priceOf)) // List(Book(FP, $35.0), Book(Scala, $45.0))
// The payoff: write once against Item, reuse with any subtype collection
val dvds = List(DVD("The Matrix", 19.99), DVD("Inception", 24.99))
val items: List[Item] = books ++ dvds
println(dvds.filter(cheap)) // List(DVD(The Matrix, $19.99), DVD(Inception, $24.99))
println(items.sortBy(priceOf)) // all four, sorted by price
// The reverse doesn't work: a Book-only function can't handle DVDs
val isbnOf: Book => String = _.isbn
// dvds.map(isbnOf) // Compile error! DVD has no isbn
// items.map(isbnOf) // Compile error! Item has no isbn
The pattern: if a type consumes A (takes it as input), it’s a candidate for -A.
If it produces A (returns it as output), it’s a candidate for +A.
Why does the direction flip? Look at val cheap: Item => Boolean = _.price < 40.0.
Every subtype of Item — Book, DVD, DigitalGiftCertificate — has .price.
So a function that only uses Item’s properties naturally works for any of them.
You write it once and reuse it with List[Book], List[DVD], or List[Item].
That’s what contravariance means in practice: a consumer of a broader type is a safe substitute for a consumer of a narrower type.
The PriceFormatter from the first example is the same idea made explicit.
An ItemFormatter only uses name and price — things every Item has. So it
can handle a Book, a DVD, anything. A BookFormatter uses isbn — something
only Book has. Hand it a DVD and it breaks. Hence the flip:
Book <: Item, but PriceFormatter[Item] <: PriceFormatter[Book].
Coming from Java?
Java’s Predicate<Item> can filter a List<Book> only because filter()
explicitly accepts Predicate<? super T> — use-site contravariance:
// Step2kCon.java — Java's use-site contravariance: ? super
import java.util.List;
import java.util.ArrayList;
import java.util.function.Predicate;
class Item {
String name;
double price;
Item(String name, double price) { this.name = name; this.price = price; }
public String toString() { return name + " ($" + price + ")"; }
}
class Book extends Item {
Book(String name, double price) { super(name, price); }
}
class DVD extends Item {
DVD(String name, double price) { super(name, price); }
}
public class Step2kCon {
// In Scala: Function1[-A, +B] — a function on Item works where Book is expected.
// The class declares -A, and it just works everywhere.
//
// In Java: Predicate<Item> is NOT Predicate<Book> — generics are invariant.
// But Predicate declares @FunctionalInterface with ? super,
// so filter() accepts Predicate<? super Book>.
// Use-site contravariance: ? super Book (write-only — like Scala's -A)
static void addBooks(List<? super Book> target) {
target.add(new Book("Scala", 45.0));
target.add(new Book("FP", 35.0));
// Book b = target.get(0); // Compile error! Can only get Object
}
public static void main(String[] args) {
// --- ? super: contravariance at the call site ---
List<Item> items = new ArrayList<>();
addBooks(items); // List<Item> → List<? super Book>
System.out.println("Items: " + items);
// --- Predicate: contravariant in its input ---
List<Book> books = List.of(
new Book("Scala", 45.0),
new Book("FP", 35.0)
);
Predicate<Item> cheap = item -> item.price < 40.0;
long count = books.stream().filter(cheap).count();
System.out.println("Cheap books: " + count);
// In Scala this is just: books.filter(_.price < 40.0)
// Function1[-A, +B] makes Item => Boolean usable as Book => Boolean.
}
}
Try it: javac Step2kCon.java && java Step2kCon
If filter() had been written as filter(Predicate<T>) without ? super,
passing a Predicate<Item> where Predicate<Book> is expected would fail —
and nothing warns you that the annotation is missing.
Scala’s Function1[-A, +B] declares contravariance once —
books.filter(cheap) just works, and the caller never thinks about it.
What about bounds for contravariance?
With covariance, the compiler said: “You declared +A, so I can’t let you take
an A as input.” Lower bounds ([B >: A]) were the escape hatch — widen upward.
The mirror exists: -A forbids A in output positions, and upper bounds
([B <: A]) are the escape hatch — narrow downward. The pairings are fixed:
| Variance | Problem | Escape hatch | Direction |
|---|---|---|---|
+A covariant | A can’t appear in input | [B >: A] lower bound | widen up |
-A contravariant | A can’t appear in output | [B <: A] upper bound | narrow down |
Lower bounds solve a covariance problem. Upper bounds solve a contravariance problem. They don’t cross — covariance widens, so its escape hatch widens; contravariance narrows, so its escape hatch narrows.
But what about bounds on the class itself — not the escape hatch, but a
constraint like [+A <: Item] or [-A >: Item]? Only one direction is useful:
| Covariance | Contravariance | |
|---|---|---|
| Escape hatch | [B >: A] lower bound | [B <: A] upper bound |
| Class bound | [+A <: Item] — useful | [-A >: Item] — code smell |
[+A <: Item] earns its keep: inside the class you can call Item methods
on A — .name, .price. You’ve already seen this with
WeakReference[+T <: AnyRef] and Shipment[+A <: Shippable].
[-A >: Item] is redundant. It prevents PriceFormatter[Book] from existing,
but contravariance already makes PriceFormatter[Book] unusable where
PriceFormatter[Item] is expected — the bound restricts at the definition
site what contravariance already handles at the use site.
See it in code: [-A >: Item]
/*
step2crossed.scala — Why [-A >: X] is (usually) a code smell
The pairings are fixed:
+A pairs with >: (lower bound)
-A pairs with <: (upper bound)
What happens if you cross them?
*/
trait Item:
def name: String
def price: Double
trait Book extends Item
case class Comic(name: String, price: Double) extends Book
case class Novel(name: String, price: Double) extends Book
case class DVD(name: String, price: Double) extends Item
/*
Item
/ \
Book DVD
/ \
Comic Novel
*/
// ------------------------------------------------------------
// Plain contravariant consumer
// ------------------------------------------------------------
trait PriceFormatter[-A]:
def format(a: A): String
def printPrices(items: List[Item], fmt: PriceFormatter[Item]): Unit =
items.foreach(i => println(fmt.format(i)))
// A narrower formatter
val bookFmt: PriceFormatter[Book] =
b => s"${b.name}: $$${b.price}"
// This does NOT compile:
//
// printPrices(items, bookFmt)
//
// Why?
/*
Type hierarchy:
Comic <: Book <: Item <: Any
Contravariance flips it:
PriceFormatter[Any]
<: PriceFormatter[Item]
<: PriceFormatter[Book]
<: PriceFormatter[Comic]
printPrices requires PriceFormatter[Item].
Only types to the LEFT (subtypes) are acceptable.
PriceFormatter[Book] is to the RIGHT.
So the compiler rejects it — already.
*/
// A wider formatter DOES work:
val anyFmt: PriceFormatter[Any] =
_.toString
// printPrices(items, anyFmt) // compiles
// ------------------------------------------------------------
// The crossed lower bound: [-A >: Item]
// ------------------------------------------------------------
// What if we added >: Item?
//
// trait PriceFormatter[-A >: Item]:
// def format(a: A): String
//
// It rejects things like:
//
// val stringFmt: PriceFormatter[String] = _.toUpperCase // rejected
//
// But even without the bound, you can't pass PriceFormatter[String]
// to printPrices — contravariance already blocks it.
// The bound prevents creating something you couldn't use anyway.
// ------------------------------------------------------------
// Takeaway
// ------------------------------------------------------------
/*
For a pure consumer type (-A), adding A >: Item
does not improve substitutability safety when
your APIs already require PriceFormatter[Item].
Variance enforces the safety property.
The bound merely narrows the universe of
possible instantiations.
If a constraint doesn't reject anything relevant
to how the type is actually used,
it's not protection — it's noise.
*/
val itemFmt: PriceFormatter[Item] =
a => s"${a.name}: $$${a.price}"
@main def step2crossed(): Unit =
val items = List(
Comic("Watchmen", 25.0),
Novel("Dune", 18.0),
DVD("The Matrix", 19.99)
)
printPrices(items, itemFmt)
A real example: ZIO
For simple consumers like PriceFormatter[-A] or Function1[-A, +B], the
contravariant escape hatch never comes up — they return String or Boolean,
not A. But it does come up when you compose contravariant types — and
seeing it in a real example is the best way to understand why the escape
hatch exists.
ZIO, one of the most popular Scala libraries, takes an interesting approach to dependency management: instead of passing dependencies (a database connection, a logger) directly to functions, you describe what you need in the type. A program becomes a value: “I need a Database to run, and I’ll produce a String.” The compiler then verifies that all dependencies are satisfied before the program runs — dependency injection at the type level.
ZIO encodes this as ZIO[-R, +E, +A]:
-R(environment) — what the program needs to run. Contravariant, as we’ve been discussing.+E(error) — how the program can fail. Covariant — a function that handlesDatabaseErroralso handles it when composed with code that fails withNetworkError, widening toDatabaseError | NetworkError.+A(value) — what the program produces on success. Covariant.
The typed error channel is a big part of what makes ZIO useful in production:
instead of catching Exception and hoping for the best, the compiler tells
you exactly which errors each operation can produce — and forces you to handle
them. Variance makes this composable: combining two operations that fail
differently gives you a union of their error types.
We’ll build a simplified version — Effect[-R, +A] — dropping the error channel
to focus on variance and upper bounds. Our Effect is just a teaching tool to
see why the contravariant escape hatch is necessary: without R1 <: R, you
can’t write flatMap — and without flatMap, you can’t combine effects.
Effect[-R, +A] is a computation that needs an environment R to run and
produces a value A. Internally it wraps a function R => A:
class Effect[-R, +A](val run: R => A)
R => A is Function1[R, A]: R sits in the -A slot, A sits in the
+B slot. So Effect[-R, +A] has the same variance shape as
Function1[-A, +B] — which makes sense, since it’s just a wrapper.
To see how Effect[-R, +A] works in practice, let’s define two traits that
represent different capabilities, and an AppEnv that provides both:
trait Database:
def lookup(id: Int): String
trait Logger:
def log(msg: String): Unit
class AppEnv extends Database, Logger:
def lookup(id: Int) = s"user-$id"
def log(msg: String) = println(s" LOG: $msg")
Database Logger
↑ ↑
└── AppEnv ──────┘
We want an effect that takes a Database and returns a String — so we
pass db => db.lookup(1), and the type becomes Effect[Database, String].
logMsg takes a String and returns an effect that needs a Logger —
it closes over the message:
val fetchUser: Effect[Database, String] = Effect(db => db.lookup(1))
val logMsg: String => Effect[Logger, Unit] = msg => Effect(logger => logger.log(msg))
Compose them with a for-comprehension, and the result is an effect that
requires Database & Logger — which we run by providing the environment:
val program: Effect[Database & Logger, Unit] =
for
user <- fetchUser
_ <- logMsg(s"fetched $user")
yield ()
program.run(AppEnv()) // LOG: fetched user-1
The for-comprehension desugars to flatMap and map — but defining
flatMap is where variance gets in the way.
The problem: flatMap and -R
Intuitively, flatMap should use the
class’s R — the same environment for both the current effect and the next one:
def flatMap[B](f: A => Effect[R, B]): Effect[R, B]
But the compiler rejects it. To see why, you need to know how the compiler reads positions:
- Method parameters are input — position is
(-) - Return types are output — position is
(+) - When types are nested, positions multiply like signs:
(+) × (+) = (+) output inside output → still output
(+) × (-) = (-) output inside input → flips to input
(-) × (-) = (+) input inside input → flips back to output
Desugar the signature: flatMap[B](f: Function1[A, Effect[R, B]]): Effect[R, B]
| Step | Where is R? | Position |
|---|---|---|
f is a method parameter | start | (-) |
Effect[R, B] is in the +B slot of Function1 | (-) × (+) | (-) |
R is in the -R slot of Effect | (-) × (-) | (+) |
Result: R lands in (+). We declared -R. Rejected.
The fix: introduce a fresh type parameter R1 <: R on the method.
Variance rules (+ and -) only apply to the class’s own type parameters.
R1 is a method type parameter — the variance checker doesn’t track it at
all. It can appear in any position. The only thing the compiler checks is that
R1 <: R holds at the call site.
def flatMap[R1 <: R, B](f: A => Effect[R1, B]): Effect[R1, B] =
Effect(r => f(run(r)).run(r))
Comparison: why does List's flatMap work without a bound?
Desugar: flatMap[B](f: Function1[A, IterableOnce[B]]): List[B]
| Step | Where is A? | Position |
|---|---|---|
f is a method parameter | start | (-) |
A is in the -A slot of Function1 | (-) × (-) | (+) |
Result: A lands in (+). We declared +A. OK. No extra nesting layer,
so no position flip — and no bound needed.
How the implementation works
R1 <: R isn’t just for the variance checker — it makes the implementation
work too. Let’s break Effect(r => f(run(r)).run(r)) into named steps,
writing Function1[-A, +B] explicitly so we can see where each type parameter lands:
// i.e. R => A
class Effect[-R, +A](val run: Function1[R, A]):
// i.e. A => Effect[R1, B]
def flatMap[R1 <: R, B](f: Function1[A, Effect[R1, B]]): Effect[R1, B] =
Effect { r => // r: R1 — the return type is Effect[R1, B]
val a = run(r) // run this effect — OK because R1 <: R
val effect2 = f(a) // build the next effect
effect2.run(r) // run it with the same environment
}
Why is r an R1, not an R? Because flatMap returns Effect[R1, B],
which wraps a function R1 => B. So r — the environment passed to
that function — has type R1. And since R1 <: R, the same r can feed
run(r) (which expects R) and effect2.run(r) (which expects R1).
This is the contravariant pattern: run expects an R, and R1 has at
least everything R has — a consumer of a broader type works with a narrower one.
You can run the complete example with scala-cli run step2g2.scala:
// step2g2.scala — Variance in action: a simplified ZIO-like effect
// An effect that requires an environment R and produces a value A.
// This is the core idea behind ZIO[-R, +E, +A] (simplified: no error channel).
class Effect[-R, +A](val run: R => A):
def map[B](f: A => B): Effect[R, B] =
Effect(r => f(run(r)))
// Intuitively, using the R from the class, the signature would be:
// def flatMap[B](f: A => Effect[R, B]): Effect[R, B]
// But the class's -R ends up in a covariant position, so the compiler rejects it.
// (See the chapter text for the full position trace.)
// Fix: introduce R1, a fresh method parameter not subject to variance checking,
// and define it as R's subtype — so R1 provides at least the same environment as R.
def flatMap[R1 <: R, B](f: A => Effect[R1, B]): Effect[R1, B] =
Effect(r => f(run(r)).run(r))
// --- Setup: a small service hierarchy ---
trait Database:
def lookup(id: Int): String
trait Logger:
def log(msg: String): Unit
class AppEnv extends Database, Logger:
def lookup(id: Int): String = s"user-$id"
def log(msg: String): Unit = println(s" LOG: $msg")
// --- Effects with different environment requirements ---
// Needs only a Database
val fetchUser: Effect[Database, String] = Effect { db =>
db.lookup(1)
}
// Needs only a Logger
val logMsg: String => Effect[Logger, Unit] = msg => Effect { logger =>
logger.log(msg)
}
@main def step2g2(): Unit =
val env = AppEnv()
// fetchUser needs Database, logMsg needs Logger
// the for-comprehension combines them — the result needs both
val program: Effect[Database & Logger, Unit] =
for
user <- fetchUser
_ <- logMsg(s"fetched $user")
yield ()
// AppEnv extends Database, Logger — it satisfies both
program.run(env) // LOG: fetched user-1
// A plain Database isn't enough — program also needs a Logger
// val db: Database = new Database { def lookup(id: Int) = s"user-$id" }
// program.run(db) // Compile error — db is not a Logger
2-8. Quick Reference
That was a lot of moving parts. Here’s everything from this chapter in one place:
Invariant (default) → No compatibility assumed
+A (covariant) → Output side — safe to widen
-A (contravariant) → Input side — safe in the opposite direction
>: (lower bound) → Escape hatch for covariant types (widen)
<: (upper bound) → Escape hatch for contravariant types (narrow)
But knowing the mechanics is only half the story. The next question is:
in practice, how often do you actually want invariant, and when should you reach for + or -?
Take a breath. The hard conceptual work is done. Step 3 shows how variance plays out in real code — shorter and more concrete.
Step 3: Variance in Practice
3-1. Covariance in Practice — You Already Use It
You don’t need custom types to see covariance — it’s in the standard library types
you use every day. Each one also shows the [B >: A] lower bound escape hatch
from section 2-5:
// step2h.scala — Covariance in the standard library
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name)"
@main def step2h(): Unit =
val book = Book("Scala in Depth", 45.0, "978-1617295")
val dvd = DVD("The Matrix", 19.99)
// --- Option[+A] ---
// Some[Book] is usable as Option[Item] — a Book result IS an Item result
val maybeBook: Option[Book] = Some(book)
val maybeItem: Option[Item] = maybeBook // covariance
println(maybeItem) // Some(Book(Scala in Depth))
// getOrElse uses [B >: A] — the lower bound escape hatch for covariance
val item: Item = maybeBook.getOrElse(dvd) // Option[Book] → fallback DVD → Item
println(item)
// --- List[+A] ---
// List[Book] is usable as List[Item]
val books: List[Book] = List(book)
val items: List[Item] = books // covariance
println(items)
// :+ uses [B >: A] — adding a DVD widens to List[Item]
val mixed: List[Item] = books :+ dvd
println(mixed) // List(Book(Scala in Depth), DVD(The Matrix))
// --- Nothing ---
// Nothing is Scala's bottom type — a subtype of every other type.
// No value of type Nothing can ever exist, but the compiler uses it as a
// placeholder: "this side has no type yet." Combined with covariance,
// Nothing <: anything, so it fits wherever a type is expected.
// --- Either[+A, +B] ---
// Either is covariant in BOTH type parameters
// Right(book) is Right[Nothing, Book] — Nothing is the Left type.
// Covariance on +A: Nothing <: String, so Either[Nothing, Book] <: Either[String, Book]
val result: Either[String, Book] = Right(book)
val wider: Either[String, Item] = result // covariance on +B: Book <: Item
println(wider)
// Same idea with Left: Left("not found") is Left[String, Nothing]
// Covariance on +B: Nothing <: Book, so Either[String, Nothing] <: Either[String, Book]
val failed: Either[String, Book] = Left("not found")
println(failed)
// orElse uses [B1 >: B] — recovery widens the Right type
val recovered: Either[String, Item] = failed.orElse(Right(dvd))
println(recovered) // Right(DVD(The Matrix))
The code introduces Nothing — Scala’s bottom type. It’s a subtype of every
type, and no value of type Nothing can ever exist.
The compiler uses it as a placeholder when one side of a type is unspecified:
Right(book)isRight[Nothing, Book]— no Left type yetLeft("error")isLeft[String, Nothing]— no Right type yetList()isList[Nothing]— empty, no element type yetNoneisOption[Nothing]— no value, no type yet
Covariance makes this work. Since Nothing <: A for any A, these always fit
wherever a type is expected. You already saw this in section 2-5: Cart() is
Cart[Nothing]. Adding a Book widens it to Cart[Book] via [B >: A].
The pattern is the same in every case: the type produces (returns, holds, emits)
values of A, so +A is natural. And when you need to add or combine values of
a different subtype, [B >: A] widens the type safely.
3-2. Contravariance in Practice — Handlers, Validators, Serializers
Contravariance is less common but appears in a very specific pattern: types that consume or process values.
// step2i.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name, $price, $isbn)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $price)"
// JSON serializer — consumes a value and produces a String
trait JsonWriter[-A]:
def write(value: A): String
class ItemWriter extends JsonWriter[Item]:
def write(value: Item): String =
s"""{"name":"${value.name}","price":${value.price}}"""
class BookWriter extends JsonWriter[Book]:
def write(value: Book): String =
s"""{"name":"${value.name}","price":${value.price},""" +
s""""isbn":"${value.isbn}"}"""
// Validator — consumes a value and returns pass/fail
trait Validator[-A]:
def validate(value: A): Boolean
class PriceValidator extends Validator[Item]:
def validate(value: Item): Boolean = value.price > 0
def serialize[A](value: A, writer: JsonWriter[A]): String =
writer.write(value)
@main def step2i(): Unit =
val itemWriter = ItemWriter()
val book = Book("Scala in Depth", 45.0, "978-1617295")
// serialize expects JsonWriter[Book] — ItemWriter handles any Item, so it qualifies
println(serialize(book, itemWriter))
// {"name":"Scala in Depth","price":45.0} — no isbn, but it works
// A BookWriter knows about Book-specific fields
val bookWriter = BookWriter()
println(serialize(book, bookWriter))
// {"name":"Scala in Depth","price":45.0,"isbn":"978-1617295"}
// The reverse doesn't work: a BookWriter can't handle arbitrary Items
// serialize(DVD("The Matrix", 19.99), bookWriter) // Compile error
// What would bookWriter do with a DVD's isbn?
// Same pattern with Validator: PriceValidator validates any Item,
// so it works on Books too
val books = List(Book("Scala", 45.0, "978-1"), Book("Free", 0.0, "978-2"))
val valid = books.filter(PriceValidator().validate)
println(valid) // List(Book(Scala, 45.0, 978-1))
You already saw this pattern in section 2-7: Function1[-A, +B] is contravariant
in its input, which is why books.filter(cheap) works when cheap is Item => Boolean.
The JsonWriter here is the same idea. serialize expects a JsonWriter[Book],
and ItemWriter (a JsonWriter[Item]) qualifies — if it can serialize any Item,
it can serialize a Book. The Validator follows the same pattern: a
PriceValidator that validates any Item works in books.filter too.
3-3. So Is Invariant Actually Useful?
Yes, but its role is narrower than you might think.
Invariant is the right choice when your type both reads and writes — mutable containers, bidirectional channels, read-write references. But in well-designed Scala code, these are relatively rare because immutability is preferred.
// step2j.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
// Invariant is correct here: a mutable cell reads AND writes
class Cell[A](var value: A):
def get: A = value // output position (+)
def set(a: A): Unit = // input position (-)
value = a
// A bidirectional codec: encodes AND decodes
trait Codec[A]:
def encode(value: A): String // A in input position (-)
def decode(raw: String): A // A in output position (+)
// Both positions → must be invariant. The compiler enforces this.
// Try adding + or - and see what happens:
// trait Codec[+A]: // error on encode
// trait Codec[-A]: // error on decode
@main def step2j(): Unit =
val cell = Cell[Item](Book("Scala", 45.0, "978-1"))
cell.set(Book("FP", 35.0, "978-2"))
println(cell.get) // Book(FP)
// Cell[Book] is NOT Cell[Item] — and shouldn't be.
// If it were, you could put a DVD in a Book cell.
The practical reality:
Most types you design in Scala fall into one of two camps:
Produces A (read-only data, results, events, streams) → make it covariant [+A]
Consumes A (handlers, writers, validators, orderings) → make it contravariant [-A]
Invariant is what you get when you don’t annotate — and often that’s because
you haven’t yet thought about whether your type is a producer or consumer.
When you do think about it, you’ll find that one of + or - usually applies.
The discipline of asking “does this type produce or consume A?” is itself
a valuable design exercise. It forces you to clarify your type’s role,
and the compiler verifies your answer.