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

Sponsored

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] where List[Animal] is expected, even though Cat <: Animal.
  • You cannot return a List[Cat] from a method declared to return List[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): If Cat <: Animal, then Box[Cat] <: Box[Animal]. The generic type varies in the same direction. This matches intuition for read-only containers.
  • Contravariance (-T): If Cat <: Animal, then Box[Animal] <: Box[Cat]. The direction reverses. This fits write-only or consumer types.
  • Invariance (no annotation): Box[Cat] and Box[Animal] have no subtyping relationship. This is the default in Python (without TypeVar variance) 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:

  1. Decide the role of your type: Is your generic type primarily a producer (returns T), a consumer (accepts T), or both?
  2. Producer → Covariant: The method returns T. Add +T.
  3. Consumer → Contravariant: The method takes T as a parameter. Add -T.
  4. Both producer and consumer → Invariant: You can't annotate — the compiler will reject your attempt.
  5. 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 +T in a method parameter. Fix: redesign your class or restrict methods to a lower (or upper) bound. Example: class List[+T] only has methods that return T, never take it (except universal Any).
  • Compiler error: contravariant type T occurs in covariant position — Using -T in a return type. Fix: remove the annotation or change the method to not return T.
  • Arrays are invariant — In Scala, Array[Cat] is not an Array[Animal]. This matches Java's runtime behavior and prevents the famous “fake array covariance” bug.
  • Function types are bothFunction1[-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 (like List[_ <: 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 because var creates both a getter (covariant) and a setter (contravariant) position.
  • Using covariance on a class that accepts T in method parameters, like class Sink[+T] { def add(x: T) } — you get a 'contravariant position' compile error.
  • Assuming Array[Cat] is a subtype of Array[Animal] because in Python lists are dynamic — Scala arrays are invariant; use Seq or List for 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

  1. Use use-site variance (wildcards) — List[_ <: Animal] — when you need a one-off subtyping relationship without modifying the class definition.
  2. Use type parameter boundsclass Container[T <: Animal] — to restrict the possible types, coupled with variance for broader flexibility.
  3. In Python's typing, you achieve similar flexibility with TypeVar(covariant=True) or contravariant=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 as List[Person] to a function expecting any Person — no casting.
  • Designing an event-processing pipeline where a Consumer[AnyEvent] is accepted where a Consumer[PaymentEvent] is expected, using contravariance.
  • Modeling a read-only configuration trait Config[+A] so that a Config[DbConfig] can be used as Config[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 a Box[Animal] and is safe for immutable containers that only return T.
  • Contravariance lets Printer[Animal] be a Printer[Cat]; it's appropriate for types that only consume T.
  • Invariance is required for any mutable type — both reading and writing T forces 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.