Scala Programming Guidesscala-advancedscala-type-level

Variance, Bounds, and Existential Types | Scala Programming Guide

By Dmitri Meshin
Picture of the author
Published on
Variance, Bounds, and Existential Types - Scala Programming Guide

Understanding Variance

Variance describes how type parameters change with subtyping. This is crucial for writing flexible, correct generic code. The core question is deceptively simple: if Dog is a subtype of Animal, what can we say about List[Dog] in relation to List[Animal]? The answer determines whether your generic types are flexible or restrictive. Understanding variance is the foundation for designing APIs that feel natural to use while maintaining type safety. Get variance wrong and your APIs will be either overly restrictive (users can't pass values that should work) or dangerously permissive (values get mixed up at runtime). Get it right and your code is both flexible and safe. This section is dense with concepts, so take your time working through the examples—the payoff is significant.

Variance comes down to understanding what operations your type supports. If your type only produces (outputs) values, covariance is safe. If it only consumes (inputs) values, contravariance is safe. If it does both, you're stuck with invariance. This simple principle explains everything about variance, and once you internalize it, you'll make good variance choices instinctively.

// The base hierarchy
sealed trait Animal
case class Dog(name: String) extends Animal
case class Cat(name: String) extends Animal
case class Bird(name: String) extends Animal

// Question: if Dog extends Animal, what can we say about List[Dog] vs List[Animal]?
// Answer: NOTHING! That's invariance.

def processAnimals(animals: List[Animal]): Unit = {
  animals.foreach(a => println(s"${a.getClass.getSimpleName}"))
}

val dogs: List[Dog] = List(Dog("Fido"), Dog("Rex"))
// This won't compile:
// processAnimals(dogs) // ERROR: List[Dog] is not a List[Animal]

// The reason: if we could pass List[Dog] to a function expecting List[Animal],
// the function could do:
val animals: List[Animal] = List(Dog("a"), Cat("b"))
// And then it could do:
// animals.append(Cat("Whiskers")) // Add a Cat to what we thought was a list of Dogs!

// This is the KEY INSIGHT about variance.

Covariance: "Producers"

Covariance is the first key type relationship: if type B extends type A, then Producer[B] extends Producer[A]. The intuition is simple: a covariant type only produces (outputs) values of its type parameter—it never consumes them (takes them as input). Think of covariance as "read-only" semantics. If I need a Producer[Animal], I can safely accept a Producer[Dog] because any Dog is an Animal. The compiler can guarantee safety: all values you produce from the Producer[Dog] will be safe to use wherever an Animal is expected. Covariance shows up everywhere: Iterator, Option, Seq, and Function return types are all covariant. Understanding when you can make a type covariant is essential for writing flexible generic APIs.

Covariance is the most intuitive variance: if your container produces values, it's safe to treat a specific container as a general one. A Dog producer is safe where an Animal producer is expected because all Dogs are Animals. This is why read-only data structures like lists and iterators can be covariant: they never need to store values that don't match the type parameter.

The notation +T indicates covariance: the + sign means the type parameter "goes in the same direction" as the subtyping relationship. If Dog extends Animal, then Producer[Dog] extends Producer[Animal]—both go "up" the hierarchy.

// A covariant container only produces (reads) elements of type T
// Think of it as read-only
trait Producer[+T] {
  def next: T // Produces a T
  // Cannot have: def put(t: T): Unit // Would need contravariance
}

// With covariance, this is safe:
def processDogProducers(dogs: Producer[Dog]): Unit = {
  val dog = dogs.next // We get a Dog, which is an Animal
  println(s"Got ${dog.name}")
}

// And we can pass a Producer[Dog] where Producer[Animal] is expected:
val dogProducer: Producer[Dog] = new Producer[Dog] {
  private var count = 0
  def next: Dog = { count += 1; Dog(s"Dog$count") }
}

// These work because Producer is covariant and only produces values:
def processDogProducersTyped(animals: Producer[Animal]): Unit = {
  val animal = animals.next
  println(s"Got ${animal.getClass.getSimpleName}")
}

processDogProducersTyped(dogProducer) // OK! List[Dog] <: Producer[Animal]

// Real-world example: Iterator[+A] is covariant
val dogIterator: Iterator[Dog] = List(Dog("Fido"), Dog("Rex")).iterator
val animalIterator: Iterator[Animal] = dogIterator // OK!

// Another real example: Function return types are covariant
trait AnimalFactory {
  def create: Animal
}

trait DogFactory extends AnimalFactory {
  def create: Dog // OK! Dog <: Animal
}

// This makes sense: if someone expects a function that creates Animals,
// a function that creates Dogs is safe to use instead.

Contravariance: "Consumers"

Contravariance reverses the subtyping relationship: if type B extends type A, then Consumer[A] extends Consumer[B]. This sounds backwards until you understand the intuition: a contravariant type only consumes (takes as input) values of its type parameter—it never produces them. Think of contravariance as "write-only" semantics. If I have a Consumer[Animal], I can safely use it where a Consumer[Dog] is expected, because I can pass any Dog (which is an Animal) to the consumer. The compiler can guarantee safety: the consumer is willing to accept anything in the Animal hierarchy, so a Dog is fine. Contravariance is less common than covariance but equally important: function parameters, event handlers, and callback types are contravariant. Mastering both covariance and contravariance is the key to understanding flexible, type-safe generic APIs.

Contravariance is the hardest variance to understand intuitively, but the logic is sound: if a consumer accepts Animals, it will happily accept Dogs (since Dogs are Animals). So a general consumer can substitute for a specific one. The notation -T indicates contravariance: the minus sign means the type parameter "goes against" the subtyping relationship. If Dog extends Animal, then Consumer[Animal] extends Consumer[Dog]—opposite directions.

Think of it this way: contravariance flips the direction. When you're consuming values, being more general (accepting Animals) is better than being specific (accepting only Dogs). So the hierarchy inverts.

// A contravariant container only consumes (writes) elements of type T
// Think of it as write-only
trait Consumer[-T] {
  def accept(t: T): Unit // Consumes a T
  // Cannot have: def get: T // Would need covariance
}

// The direction reverses!
// If Consumer is contravariant, then:
// Consumer[Animal] can be used where Consumer[Dog] is expected
// NOT the other way around

def feedDogs(consumer: Consumer[Dog]): Unit = {
  consumer.accept(Dog("Buddy"))
}

// This works: an AnimalConsumer can consume a Dog (since Dog is an Animal)
val animalConsumer: Consumer[Animal] = new Consumer[Animal] {
  def accept(animal: Animal): Unit = {
    println(s"Consuming ${animal.getClass.getSimpleName}")
  }
}

// This is contravariance in action:
feedDogs(animalConsumer) // OK! Consumer[Animal] <: Consumer[Dog]

// Real-world example: Function parameters are contravariant
trait DogProcessor {
  def process(dog: Dog): Unit
}

trait AnimalProcessor extends (Dog => Unit) {
  def apply(animal: Animal): Unit = {
    println(s"Processing ${animal.getClass.getSimpleName}")
  }
}

// This makes sense: if someone wants to process a Dog,
// a function that processes any Animal is safe to use.

// Visualization:
//   Covariance (+T):     Dog <: Animal ⟹ Producer[Dog] <: Producer[Animal]
//   Contravariance (-T): Dog <: Animal ⟹ Consumer[Animal] <: Consumer[Dog]
//   Invariance (T):      Dog <: Animal ⟹ NO relationship

When to Use Which Variance

Now that you understand covariance and contravariance, the question becomes: when should you use each? The answer lies in examining your type's operations: if your type primarily produces/outputs values of the type parameter, use covariance. If it primarily consumes/inputs values, use contravariance. If it does both, you're stuck with invariance—the type parameter can't vary. The classic mnemonic from Java's generics world is helpful here: "PECS" - Producer Extends (covariant), Consumer Super (contravariant). This principle guides you toward safe, flexible designs. When you get variance right, your APIs become more usable: users can pass subtypes where supertypes are expected, reducing boilerplate. When you get it wrong, you either create overly restrictive APIs or introduce type safety holes. Let's explore practical examples that show the right variance choice for each situation.

Getting variance right is a skill that comes with practice. Start by asking: does this type produce values (covariant), consume values (contravariant), or both (invariant)? Answering that question automatically tells you which variance to use. Over time, you'll develop intuition and make variance choices without conscious thought—you'll just "feel" what's right.

// A metrics collection system demonstrating variance

// Covariant - produces metrics (read-only from client perspective)
trait MetricSource[+M] {
  def getMetrics: List[M]
  def nextMetric: M
}

// Contravariant - consumes metrics (write-only from client perspective)
trait MetricSink[-M] {
  def recordMetric(m: M): Unit
  def recordBatch(metrics: List[M]): Unit
}

// Invariant - both reads and writes (no variance)
trait MetricStore[M] {
  def get: M
  def put(m: M): Unit
}

case class CpuMetric(usage: Double)
case class Metric(source: String, value: Double)

// Example: a CPU monitor produces CPU metrics
val cpuMonitor = new MetricSource[CpuMetric] {
  def getMetrics = List(CpuMetric(45.2), CpuMetric(62.1))
  def nextMetric = CpuMetric(scala.math.random() * 100)
}

// We can use it where a MetricSource[Metric] is expected
// because MetricSource is covariant
def analyzeMetrics(source: MetricSource[Metric]): Unit = {
  println("Analyzing metrics...")
  source.getMetrics.foreach(m => println(s"Got metric: $m"))
}

// This compiles because CpuMetric could be treated as Metric
// (Actually, it wouldn't without an actual subtyping relationship,
//  but this illustrates the principle)

// Example: a sink that records any metric
val universalSink = new MetricSink[Metric] {
  def recordMetric(m: Metric) = println(s"Recorded: $m")
  def recordBatch(metrics: List[Metric]) =
    metrics.foreach(recordMetric)
}

// We can use it where a MetricSink[CpuMetric] is expected
// because MetricSink is contravariant
def recordCpuMetrics(sink: MetricSink[CpuMetric]): Unit = {
  sink.recordMetric(CpuMetric(50.0))
}

// This compiles because the universal sink can handle any Metric,
// including CpuMetrics

// INVARIANT - when you read AND write, no variance
trait MutableMetricCache[M] {
  def get: M
  def set(m: M): Unit
}

// You cannot pass MutableMetricCache[CpuMetric] where
// MutableMetricCache[Metric] is expected, because you might try to put
// a non-CPU metric into it!

Variance and Method Parameters

Here's where things get subtle. Method parameters are contravariant in the return type being covariant. Understanding this layering of variances is essential for designing correct generic code. When you have a function type, both the parameter type and return type have variance, but they vary in opposite directions. This is why Function[-A, +B] is contravariant in A and covariant in B.

// A permission system demonstrating this subtlety
sealed trait Permission
case object Read extends Permission
case object Write extends Permission

trait SecureFunction[-In, +Out] {
  def apply(in: In): Out
}

// A function that takes any permission and returns a Boolean
val permissionChecker: SecureFunction[Permission, Boolean] =
  new SecureFunction[Permission, Boolean] {
    def apply(perm: Permission) = true
  }

// Now, can we use permissionChecker where a function expecting Read is needed?
// Function[-In, +Out]: contravariant in In, covariant in Out
// permissionChecker takes Permission (broader) and returns Boolean
// if someone expects a function that takes Read and returns Boolean,
// a function that takes Permission (any permission) is MORE general and thus acceptable

val readChecker: SecureFunction[Read.type, Boolean] = permissionChecker
// This works because SecureFunction is contravariant in the input type

// Real-world example: Scala's Function trait
// trait Function1[-A, +R]

def processRead(f: Read => Boolean): Boolean = {
  f(Read)
}

// A function that accepts any Permission can be used where
// a function that accepts Read is expected:
val anyPermissionFilter: Permission => Boolean =
  p => p == Read || p == Write

processRead(anyPermissionFilter) // Works!

// The rule: When assigning supertypes to subtypes (widening),
// for Function[-In, +Out], you can use a function with:
// - More general inputs (contravariance)
// - More specific outputs (covariance)

Upper Bounds: Constraining Type Parameters

An upper bound restricts what types a type parameter can be. If you write T <: Animal, you're saying "T must be Animal or a subtype of Animal." This constraint lets you call methods on the type parameter—you know it has all the methods that Animal has. Upper bounds are essential for writing generic code that performs operations specific to a family of types. They're also useful for expressing relationships: "T must implement Comparable" or "T must be some kind of Animal." This section explores how to use upper bounds to write powerful generic code while maintaining type safety.

Upper bounds are particularly valuable when you want to call specific methods on type parameters. Without a bound, T is essentially unknown—you can't assume it has any methods. With a bound like T <: Comparable[T], you know T has comparison methods, so you can write code that compares values of type T.

// A plugin system with bounded type parameters
trait Plugin {
  def name: String
  def version: String
}

case class AuthPlugin(name: String, version: String) extends Plugin
case class CachePlugin(name: String, version: String) extends Plugin

// This type parameter is bounded: T must be a subtype of Plugin
trait PluginManager[T <: Plugin] {
  def register(plugin: T): Unit
  def listVersions: Map[String, String] = Map() // Can access Plugin methods

  // Can call methods from Plugin:
  def getPluginInfo(plugin: T): String =
    s"${plugin.name} v${plugin.version}"
}

val authManager = new PluginManager[AuthPlugin] {
  private var plugins = scala.collection.mutable.Set[AuthPlugin]()
  def register(plugin: AuthPlugin) = plugins.add(plugin)
}

authManager.register(AuthPlugin("OAuth2", "2.1.0"))

// Compiler knows that T is a Plugin, so getPluginInfo works:
println(authManager.getPluginInfo(AuthPlugin("LDAP", "1.0.0")))

// Upper bounds also work with traits:
trait Comparable[T <: Comparable[T]] {
  def compare(other: T): Int
}

case class Version(major: Int, minor: Int) extends Comparable[Version] {
  def compare(other: Version): Int = {
    val majorCmp = major.compare(other.major)
    if (majorCmp != 0) majorCmp else minor.compare(other.minor)
  }
}

val versions = List(
  Version(1, 5),
  Version(2, 0),
  Version(1, 9),
  Version(3, 0)
)

// This pattern is called F-bounded polymorphism
// It ensures type safety at the cost of complexity

Lower Bounds: The Inverse

Lower bounds are less common but important for covariance and immutability. A lower bound T >: X means "T is at least as specific as X"—in other words, X is a supertype of T. Lower bounds are typically used with covariant type parameters: they let you write methods on a covariant type that need to accept values that are less specific than the type parameter. Without lower bounds, covariant types would be severely restricted.

Lower bounds enable a powerful pattern: you have a covariant container of Specific types, but you want to add General types to it. The result is a container of General types, which is perfectly safe. This is the mechanism behind Scala's append-like methods on immutable data structures.

// An immutable data structure demonstrating lower bounds
sealed class LinkedList[+T](val head: T, val tail: LinkedList[T])

// Problem: we want to add an element, but LinkedList is covariant
// We can't just do:
// def add(elem: T): LinkedList[T] = ... // Error: contravariance!

// Solution: use a lower bound (B >: T means B is a supertype of T)
sealed class SafeLinkedList[+T](
  val head: T,
  val tail: SafeLinkedList[T]
) {
  // add accepts B, which is at least as general as T
  // So if we have a SafeLinkedList[Dog], we can add an Animal to it
  // The result is SafeLinkedList[Animal] (the common supertype)
  def add[B >: T](elem: B): SafeLinkedList[B] = {
    new SafeLinkedList(elem, this)
  }
}

// Example
val dogList = new SafeLinkedList[Dog](
  Dog("Fido"),
  new SafeLinkedList[Dog](
    Dog("Rex"),
    null // Simplified for example
  )
)

// When we add an Animal, we get SafeLinkedList[Animal]
val mixedList = dogList.add(Cat("Whiskers"))
// mixedList: SafeLinkedList[Animal]

// This type-checks correctly and maintains type safety!

// Another example: extracting with a lower bound
case class Box[+T](value: T) {
  // get returns the value as its actual type
  def get: T = value

  // put with a lower bound
  def put[B >: T](elem: B): Box[B] = Box(elem)
}

val dogBox: Box[Dog] = Box(Dog("Buddy"))
val animalBox: Box[Animal] = dogBox.put(Cat("Mittens"))
// animalBox now contains the Cat, with type Box[Animal]

Context Bounds: Syntactic Sugar

Context bounds are a cleaner way to express implicit type class requirements. The syntax T: Ordering is shorthand for (using Ordering[T]): it expresses that T must have an Ordering instance without requiring you to explicitly name the parameter. This makes function signatures more readable, especially when you have multiple context bounds. Context bounds are also forward-compatible: if you need to explicitly use the type class instance, you can always summon it.

Context bounds shine when you're working with multiple type class constraints. Instead of writing function[T](using Ordering[T], Numeric[T], Show[T]), you can write function[T: Ordering: Numeric: Show], which is much cleaner. They're also the preferred style in modern Scala code because they put focus on the constraints rather than the mechanism.

// Context bounds make implicit parameters more readable

// Verbose style with implicit parameter
def sortListOld[T](list: List[T])(implicit ord: Ordering[T]): List[T] = {
  list.sorted(using ord)
}

// Cleaner with context bound
def sortList[T: Ordering](list: List[T]): List[T] = {
  list.sorted
  // The compiler automatically provides the Ordering[T] instance
}

// Even cleaner - use summon when needed
def findMin[T: Ordering](list: List[T]): Option[T] = {
  if (list.isEmpty) None
  else {
    val ord = summon[Ordering[T]]
    Some(list.minBy(identity)(using ord))
  }
}

println(findMin(List(3, 1, 4, 1, 5, 9)))           // Some(1)
println(findMin(List("zebra", "apple", "banana"))) // Some(apple)

// Multiple context bounds
def mergeAndSort[T: Ordering, M: Monoid](a: List[T], b: List[T]): List[T] = {
  // Both Ordering[T] and Monoid[M] are available via summon if needed
  (a ++ b).sorted
}

// Context bounds work with custom type classes
trait Logger[T] {
  def log(t: T): Unit
}

given Logger[String] with {
  def log(s: String) = println(s"[LOG] $s")
}

def logValue[T: Logger](value: T): Unit = {
  summon[Logger[T]].log(value)
}

logValue("Application started") // [LOG] Application started

Existential Types and Wildcards

Existential types let you work with types you don't fully know at compile time. They're useful when you have a collection of items with different type parameters, or when you're working with third-party code that doesn't provide full type information. The notation List[_ <: Plugin] means "a list of something that's a subtype of Plugin, but we don't know what exactly." Wildcards are an essential tool for dealing with partially-known types.

Existential types are often the answer when you feel like you're wrestling with the type system: you have values of different types that share a common interface, but you want to store them in a collection. Existential types let you do this safely, with the knowledge that you're working with partially-known information.

// A plugin registry that doesn't know plugin types at compile time
sealed trait Plugin
case class AuthPlugin(name: String) extends Plugin
case class CachePlugin(name: String) extends Plugin

// Wildcard: "some type we don't care about"
val unknownPlugins: List[_ <: Plugin] = List(
  AuthPlugin("OAuth"),
  CachePlugin("Redis")
)

// You can read from the list
unknownPlugins.foreach(p => println(p.getClass.getSimpleName))

// But you can't add to it (covariance)
// unknownPlugins += AuthPlugin("LDAP") // Won't compile

// Existential type with forSome (Scala 2 style, less common in Scala 3)
// type AnyPlugin = T forSome { type T <: Plugin }
// Instead, in Scala 3, use wildcards or generic types

// More practical: working with unknown type parameters
trait Repository {
  def getAll: List[_] // List of unknown type
}

val repositories: List[Repository] = List(???, ???)

repositories.foreach(repo => {
  val items = repo.getAll
  println(s"Found ${items.length} items") // This works
  // But we can't do much with the items since we don't know their type
})

// Better approach: use a wrapper type
trait RepositoryEntry {
  type Item
  def getAll: List[Item]
}

trait IntRepository extends RepositoryEntry {
  type Item = Int
  def getAll = List(1, 2, 3)
}

// Now RepositoryEntry.Item is abstract, but we can work with it:
val entry: RepositoryEntry = new IntRepository {}
val items = entry.getAll // List of unknown type, but we know the structure

Complete Variance Example: A Type-Safe Metrics System

// Tying it all together with a real metrics system

sealed trait MetricType
case object Cpu extends MetricType
case object Memory extends MetricType
case object Network extends MetricType

// Covariant source - only produces metrics
trait MetricSource[+T <: MetricType] {
  def readMetric: T
  def read100Metrics: List[T]
}

// Contravariant sink - only accepts metrics
trait MetricSink[-T <: MetricType] {
  def writeMetric(metric: T): Unit
  def write(metrics: List[T]): Unit
}

// Invariant database - reads and writes
trait MetricDatabase[T <: MetricType] {
  def read: T
  def write(m: T): Unit
}

// Concrete implementations
class CpuMonitor extends MetricSource[Cpu.type] {
  def readMetric = Cpu
  def read100Metrics = List.fill(100)(Cpu)
}

class InfluxdbSink extends MetricSink[MetricType] {
  def writeMetric(metric: MetricType) = println(s"Writing $metric to InfluxDB")
  def write(metrics: List[MetricType]) =
    metrics.foreach(writeMetric)
}

class MemoryMonitor extends MetricSource[Memory.type] {
  def readMetric = Memory
  def read100Metrics = List.fill(100)(Memory)
}

// Function that uses covariance
def collectMetrics(source: MetricSource[MetricType]): List[MetricType] = {
  source.read100Metrics
}

// Function that uses contravariance
def persistMetrics[T <: MetricType](metrics: List[T], sink: MetricSink[T]): Unit = {
  sink.write(metrics)
}

// Usage demonstrating variance
val cpuSource: MetricSource[Cpu.type] = new CpuMonitor()
val universalSink: MetricSink[MetricType] = new InfluxdbSink()

// Covariance allows this:
val anyMetrics = collectMetrics(cpuSource)

// Contravariance allows this:
persistMetrics(List(Cpu, Memory, Network), universalSink)