Variance Annotations for Subtyping
Learn to control subtyping relationships in Scala generics with variance annotations. Understand covariance, contravariance, and invariance through practical examples tailored for Python developers. Master when to use each annotation to design flexible and type-safe APIs. Hands-on exercises and troubleshooting tips inc
Focus: variance annotations for subtyping
You've spent years in Python, where list accepts anything and type errors appear at runtime. Now you're writing Scala generics and suddenly a List[Cat] is not a List[Animal] — the compiler refuses your most natural code. This is the pain of invariance by default. In this lesson, you'll learn variance annotations (+, -, and the absence of both) to control subtyping relationships in generic types, so you can design APIs that are both flexible and type-safe, just like Python's duck typing but with compile-time guarantees.
The problem this lesson solves
Scala's type system is strict about subtyping in generics. In Python, if you have a function that takes a list of animals, you can pass a list of cats — no one cares until runtime. In Scala, List[Cat] and List[Animal] are different types. Without variance annotations, your code hits walls:
- You cannot pass
List[Cat]whereList[Animal]is expected, even thoughCat <: Animal. - You cannot return a
List[Cat]from a method declared to returnList[Animal]. - Generic classes like
Box[T]don't automatically inherit subtyping relationships from their type parameters.
This rigidity exists to protect you from runtime errors and unsound operations — variance annotations let you relax it safely.
Core concept / mental model
Think of variance as a rule for how a generic type's subtyping relationship follows its type argument's subtyping relationship. In Python, think of TypeVar with covariant or contravariant in typing — same idea, but Scala bakes it into the class definition.
- Covariance (
+T): IfCat <: Animal, thenBox[Cat] <: Box[Animal]. The generic type varies in the same direction. This matches intuition for read-only containers. - Contravariance (
-T): IfCat <: Animal, thenBox[Animal] <: Box[Cat]. The direction reverses. This fits write-only or consumer types. - Invariance (no annotation):
Box[Cat]andBox[Animal]have no subtyping relationship. This is the default in Python (withoutTypeVarvariance) and in Scala.
In Python, you've probably used typing.Sequence (covariant) and typing.Callable (contravariant in arguments). Scala formalizes these at the type level.
How it works step by step
To add a variance annotation, follow these steps:
- Decide the role of your type: Is your generic type primarily a producer (returns
T), a consumer (acceptsT), or both? - Producer → Covariant: The method returns
T. Add+T. - Consumer → Contravariant: The method takes
Tas a parameter. Add-T. - Both producer and consumer → Invariant: You can't annotate — the compiler will reject your attempt.
- Check the compiler: Scala enforces variance positions — covariant type parameters can't appear in method parameters, and contravariant ones can't appear in method return types.
Hands-on walkthrough
Let's start with a simple covariant container. Create a file VarianceDemo.scala:
class Box[+T](val value: T)
class Animal
class Cat extends Animal
object VarianceDemo extends App {
val catBox: Box[Cat] = new Box(new Cat)
val animalBox: Box[Animal] = catBox // Covariance: allowed
println(animalBox.value) // Works because we only read T
}
Run it with scala VarianceDemo.scala — the output is the default Cat object's toString. The key point: Box[Cat] is a Box[Animal] because Box is covariant.
Now a contravariant example — a consumer that writes or processes T:
class Printer[-T] {
def print(value: T): Unit = println(value)
}
class Animal { override def toString = "Animal" }
class Cat extends Animal { override def toString = "Cat" }
object VarianceDemo2 extends App {
val animalPrinter: Printer[Animal] = new Printer[Animal]
val catPrinter: Printer[Cat] = animalPrinter // Contravariance: allowed
catPrinter.print(new Cat) // Prints "Cat"
}
Why is this safe? A Printer[Animal] can print any Animal, so it can certainly print a Cat. Contravariance lets you use a more general printer where a specific one is needed.
What if T is both produced and consumed? You get invariance — and the compiler forbids annotations:
class MutableBox[T](var value: T) // No annotation allowed — invariant
// If you try +T, you'd get: covariant type T occurs in contravariant position
For a functional comparison, here's a Python analog using TypeVar:
from typing import TypeVar, Generic
T_co = TypeVar("T_co", covariant=True)
class Box(Generic[T_co]):
def __init__(self, value):
self._value = value
def get(self) -> T_co:
return self._value
But Python doesn't enforce variance — it's only a hint for type checkers. Scala makes it a contract.
Compare options / when to choose what
| Scenario | Python (typing) | Scala annotation | Example |
|---|---|---|---|
| Read-only container (producer) | TypeVar(covariant=True) |
+T |
List[+T], Option[+T] |
| Consumer / processor (contravariant) | TypeVar(contravariant=True) |
-T |
Printer[-T], Function1[-T, +R] |
| Mutable container / both roles | No variance (default) | no annotation | Array[T], Buffer[T] |
Covariance is the safest default for immutable data — it matches Python's Sequence. Contravariance appears in “sink” abstractions like writers, predicates, or event handlers. Invariance is necessary for any mutable state, because you could write an incompatible value.
Troubleshooting & edge cases
- Compiler error:
covariant type T occurs in contravariant position— This happens when you try to use+Tin a method parameter. Fix: redesign your class or restrict methods to a lower (or upper) bound. Example:class List[+T]only has methods that returnT, never take it (except universalAny). - Compiler error:
contravariant type T occurs in covariant position— Using-Tin a return type. Fix: remove the annotation or change the method to not returnT. - Arrays are invariant — In Scala,
Array[Cat]is not anArray[Animal]. This matches Java's runtime behavior and prevents the famous “fake array covariance” bug. - Function types are both —
Function1[-A, +B]is contravariant in input and covariant in output. This is the standard for functional programming. - Variance and Java interop — When mixing with Java, you'll need
_ <:and_ >:wildcards (likeList[_ <: Animal]) because Java's generics are invariant. Scala annotations don't pop out to Java.
What you learned & what's next
You now understand the core idea behind use variance annotations for subtyping: + for producers, - for consumers, and nothing for mutable types. You completed a practical exercise that shows Box[Cat] being accepted where Box[Animal] is expected. You also learned to read and fix variance compiler errors.
These skills matter because they let you design APIs that are composable and intuitive — a Scala List[Cat] can be used as a List[Animal], saving you from casting or boilerplate.
Next up: In the next lesson in this track, you'll apply variance to your own generic ADTs and sealed traits — think of a sealed Result[+A, +B] where covariance gives you natural error handling. You'll build type-safe expressions with no runtime surprises.
Practice recap
Try this: Define a covariant ReadOnlyBox[+T] with a get method, and an invariant MutableBox[T] with get and set. Write a function that accepts ReadOnlyBox[Animal] and pass a ReadOnlyBox[Cat]. Then try to do the same with MutableBox and observe the compile error. This solidifies why + only works for producers.
Common mistakes
- Declaring a mutable class as
class Box[+T](var value: T)— the compiler rejects it becausevarcreates both a getter (covariant) and a setter (contravariant) position. - Using covariance on a class that accepts
Tin method parameters, likeclass Sink[+T] { def add(x: T) }— you get a 'contravariant position' compile error. - Assuming
Array[Cat]is a subtype ofArray[Animal]because in Python lists are dynamic — Scala arrays are invariant; useSeqorListfor covariance. - Forgetting that variance annotations must be at the class level; you cannot declare variance on a per-usage basis (that's what wildcards like
_ <:are for).
Variations
- Use use-site variance (wildcards) —
List[_ <: Animal]— when you need a one-off subtyping relationship without modifying the class definition. - Use type parameter bounds —
class Container[T <: Animal]— to restrict the possible types, coupled with variance for broader flexibility. - In Python's
typing, you achieve similar flexibility withTypeVar(covariant=True)orcontravariant=True, though it's only a static hint without runtime enforcement.
Real-world use cases
- Building a functional library with
List[User]that can be passed asList[Person]to a function expecting anyPerson— no casting. - Designing an event-processing pipeline where a
Consumer[AnyEvent]is accepted where aConsumer[PaymentEvent]is expected, using contravariance. - Modeling a read-only configuration trait
Config[+A]so that aConfig[DbConfig]can be used asConfig[BaseConfig]in dependency injection.
Key takeaways
- Variance annotations control subtyping relationships for generic types:
+for covariant (producer),-for contravariant (consumer), none for invariant. - Covariance lets
Box[Cat]be aBox[Animal]and is safe for immutable containers that only returnT. - Contravariance lets
Printer[Animal]be aPrinter[Cat]; it's appropriate for types that only consumeT. - Invariance is required for any mutable type — both reading and writing
Tforces the compiler to reject variance annotations. - Scala enforces variance positions strictly; encountering 'variance position' errors is a signal to redesign your class with the correct annotations.
- Variance is a compile-time guarantee — unlike Python's optional type hints — preventing runtime type mismatches.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.