Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 <: Item reads 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: A would allow assigning a DVD into what is actually a Box[Book]
  • contains(DVD(...)) would appear valid — since a DVD is an Item

However, the underlying object remains a Box[Book]. To preserve type safety, Scala rejects both cases:

  • var value: A“invariant position” — a var is both read and write
  • contains(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. While contains would be harmless in this case, other members — like var 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 valuesOption.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/mergingEither’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 B to inherit the <: Shippable bound from the class — after all, A is already constrained. But B is its own type parameter, introduced on the method. It only knows what you tell it.

And B >: A points up the hierarchy. In Scala, every type has AnyRef and Any above it, so without a cap B could widen all the way: Book → Shippable → Item → AnyRef → Any. The <: Shippable says “stop here.” Without it, Shipment[B] wouldn’t compile — Shipment requires 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 libraryWeakReference[+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 ItemBook, 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:

VarianceProblemEscape hatchDirection
+A covariantA can’t appear in input[B >: A] lower boundwiden up
-A contravariantA can’t appear in output[B <: A] upper boundnarrow 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:

CovarianceContravariance
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 handles DatabaseError also handles it when composed with code that fails with NetworkError, widening to DatabaseError | 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]

StepWhere is R?Position
f is a method parameterstart(-)
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]

StepWhere is A?Position
f is a method parameterstart(-)
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.