Step 3: Variance in Practice
3-1. Covariance in Practice — You Already Use It
You don’t need custom types to see covariance — it’s in the standard library types
you use every day. Each one also shows the [B >: A] lower bound escape hatch
from section 2-5:
// step2h.scala — Covariance in the standard library
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name)"
@main def step2h(): Unit =
val book = Book("Scala in Depth", 45.0, "978-1617295")
val dvd = DVD("The Matrix", 19.99)
// --- Option[+A] ---
// Some[Book] is usable as Option[Item] — a Book result IS an Item result
val maybeBook: Option[Book] = Some(book)
val maybeItem: Option[Item] = maybeBook // covariance
println(maybeItem) // Some(Book(Scala in Depth))
// getOrElse uses [B >: A] — the lower bound escape hatch for covariance
val item: Item = maybeBook.getOrElse(dvd) // Option[Book] → fallback DVD → Item
println(item)
// --- List[+A] ---
// List[Book] is usable as List[Item]
val books: List[Book] = List(book)
val items: List[Item] = books // covariance
println(items)
// :+ uses [B >: A] — adding a DVD widens to List[Item]
val mixed: List[Item] = books :+ dvd
println(mixed) // List(Book(Scala in Depth), DVD(The Matrix))
// --- Nothing ---
// Nothing is Scala's bottom type — a subtype of every other type.
// No value of type Nothing can ever exist, but the compiler uses it as a
// placeholder: "this side has no type yet." Combined with covariance,
// Nothing <: anything, so it fits wherever a type is expected.
// --- Either[+A, +B] ---
// Either is covariant in BOTH type parameters
// Right(book) is Right[Nothing, Book] — Nothing is the Left type.
// Covariance on +A: Nothing <: String, so Either[Nothing, Book] <: Either[String, Book]
val result: Either[String, Book] = Right(book)
val wider: Either[String, Item] = result // covariance on +B: Book <: Item
println(wider)
// Same idea with Left: Left("not found") is Left[String, Nothing]
// Covariance on +B: Nothing <: Book, so Either[String, Nothing] <: Either[String, Book]
val failed: Either[String, Book] = Left("not found")
println(failed)
// orElse uses [B1 >: B] — recovery widens the Right type
val recovered: Either[String, Item] = failed.orElse(Right(dvd))
println(recovered) // Right(DVD(The Matrix))
The code introduces Nothing — Scala’s bottom type. It’s a subtype of every
type, and no value of type Nothing can ever exist.
The compiler uses it as a placeholder when one side of a type is unspecified:
Right(book)isRight[Nothing, Book]— no Left type yetLeft("error")isLeft[String, Nothing]— no Right type yetList()isList[Nothing]— empty, no element type yetNoneisOption[Nothing]— no value, no type yet
Covariance makes this work. Since Nothing <: A for any A, these always fit
wherever a type is expected. You already saw this in section 2-5: Cart() is
Cart[Nothing]. Adding a Book widens it to Cart[Book] via [B >: A].
The pattern is the same in every case: the type produces (returns, holds, emits)
values of A, so +A is natural. And when you need to add or combine values of
a different subtype, [B >: A] widens the type safely.
3-2. Contravariance in Practice — Handlers, Validators, Serializers
Contravariance is less common but appears in a very specific pattern: types that consume or process values.
// step2i.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name, $price, $isbn)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $price)"
// JSON serializer — consumes a value and produces a String
trait JsonWriter[-A]:
def write(value: A): String
class ItemWriter extends JsonWriter[Item]:
def write(value: Item): String =
s"""{"name":"${value.name}","price":${value.price}}"""
class BookWriter extends JsonWriter[Book]:
def write(value: Book): String =
s"""{"name":"${value.name}","price":${value.price},""" +
s""""isbn":"${value.isbn}"}"""
// Validator — consumes a value and returns pass/fail
trait Validator[-A]:
def validate(value: A): Boolean
class PriceValidator extends Validator[Item]:
def validate(value: Item): Boolean = value.price > 0
def serialize[A](value: A, writer: JsonWriter[A]): String =
writer.write(value)
@main def step2i(): Unit =
val itemWriter = ItemWriter()
val book = Book("Scala in Depth", 45.0, "978-1617295")
// serialize expects JsonWriter[Book] — ItemWriter handles any Item, so it qualifies
println(serialize(book, itemWriter))
// {"name":"Scala in Depth","price":45.0} — no isbn, but it works
// A BookWriter knows about Book-specific fields
val bookWriter = BookWriter()
println(serialize(book, bookWriter))
// {"name":"Scala in Depth","price":45.0,"isbn":"978-1617295"}
// The reverse doesn't work: a BookWriter can't handle arbitrary Items
// serialize(DVD("The Matrix", 19.99), bookWriter) // Compile error
// What would bookWriter do with a DVD's isbn?
// Same pattern with Validator: PriceValidator validates any Item,
// so it works on Books too
val books = List(Book("Scala", 45.0, "978-1"), Book("Free", 0.0, "978-2"))
val valid = books.filter(PriceValidator().validate)
println(valid) // List(Book(Scala, 45.0, 978-1))
You already saw this pattern in section 2-7: Function1[-A, +B] is contravariant
in its input, which is why books.filter(cheap) works when cheap is Item => Boolean.
The JsonWriter here is the same idea. serialize expects a JsonWriter[Book],
and ItemWriter (a JsonWriter[Item]) qualifies — if it can serialize any Item,
it can serialize a Book. The Validator follows the same pattern: a
PriceValidator that validates any Item works in books.filter too.
3-3. So Is Invariant Actually Useful?
Yes, but its role is narrower than you might think.
Invariant is the right choice when your type both reads and writes — mutable containers, bidirectional channels, read-write references. But in well-designed Scala code, these are relatively rare because immutability is preferred.
// step2j.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
// Invariant is correct here: a mutable cell reads AND writes
class Cell[A](var value: A):
def get: A = value // output position (+)
def set(a: A): Unit = // input position (-)
value = a
// A bidirectional codec: encodes AND decodes
trait Codec[A]:
def encode(value: A): String // A in input position (-)
def decode(raw: String): A // A in output position (+)
// Both positions → must be invariant. The compiler enforces this.
// Try adding + or - and see what happens:
// trait Codec[+A]: // error on encode
// trait Codec[-A]: // error on decode
@main def step2j(): Unit =
val cell = Cell[Item](Book("Scala", 45.0, "978-1"))
cell.set(Book("FP", 35.0, "978-2"))
println(cell.get) // Book(FP)
// Cell[Book] is NOT Cell[Item] — and shouldn't be.
// If it were, you could put a DVD in a Book cell.
The practical reality:
Most types you design in Scala fall into one of two camps:
Produces A (read-only data, results, events, streams) → make it covariant [+A]
Consumes A (handlers, writers, validators, orderings) → make it contravariant [-A]
Invariant is what you get when you don’t annotate — and often that’s because
you haven’t yet thought about whether your type is a producer or consumer.
When you do think about it, you’ll find that one of + or - usually applies.
The discipline of asking “does this type produce or consume A?” is itself
a valuable design exercise. It forces you to clarify your type’s role,
and the compiler verifies your answer.