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 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) is Right[Nothing, Book] — no Left type yet
  • Left("error") is Left[String, Nothing] — no Right type yet
  • List() is List[Nothing] — empty, no element type yet
  • None is Option[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.