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.