Skip to Content
Course content

114: Common Scala Interview Questions on Variance

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Variance is usually the part of the Scala interview where candidates start sweating. It's a bit abstract, and if you haven't had to design a complex library, you might feel like it's just academic noise. But in practice, it's all about one thing: type safety when dealing with hierarchies.

Why can't I just make everything covariant?

It's tempting to just slap a +T on every generic class to make your life easier. After all, if a Dog is an Animal, it feels natural that a List[Dog] should be a List[Animal]. That's covariance.

The problem is that covariance is only safe for producers. If your class also consumes values (meaning it has a method that takes T as an argument), you've just created a hole in the type system. Look at this:

case class Box[+T](value: T) {
  // This wouldn't actually compile in Scala!
  def put(item: T): Unit = { println(s"Putting $item in the box") }
}

If Scala allowed the put method above, I could take a Box[Dog], cast it to a Box[Animal] (which covariance allows), and then try to put a Cat into it. Your Box[Dog] now contains a Cat, and the next time you try to call a dog-specific method on that value, the JVM will crash. I've seen this mistake in countless PRs; just remember: if you're writing to it, you can't be covariant.

Where do I actually use contravariance in a real project?

Contravariance (-T) is the mirror image of covariance, and it's much rarer, which is why people struggle with it. While covariance is for producers, contravariance is for consumers.

The classic example is a Comparator or a Handler. Imagine you have a hierarchy of Animal > Dog > Pug. If you have a Handler[Animal], that handler knows how to process any animal. Therefore, it is perfectly capable of handling a Dog. In this world, the Handler[Animal] is actually a subtype of Handler[Dog].

trait Handler[-T] {
  def handle(item: T): Unit
}

val animalHandler: Handler[Animal] = new Handler[Animal] {
  def handle(item: Animal): Unit = println(s"Handling animal: ${item}")
}

// This works because of -T
val dogHandler: Handler[Dog] = animalHandler 

If you make Handler invariant, the line val dogHandler: Handler[Dog] = animalHandler would fail. By using -T, you're telling the compiler: "This class consumes T, so a handler for a general type can always stand in for a handler of a specific type."

What's the deal with those weird lower bounds in covariant classes?

You've probably noticed that in the Scala standard library, methods on covariant classes often look like this: def cons[U >: T](that: U): List[U]. That U >: T is a lower bound, and it's the "escape hatch" that allows covariant classes to still have methods that consume values.

Since we can't use T directly as a parameter in a +T class, we introduce a new type parameter U that is a supertype of T. I like to think of it as the compiler saying, "I can't let you put a T in here because T is too flexible, but if you tell me the exact common supertype you're aiming for, we can make it work."

Here is how that looks in a simplified version of a Box:

case class Box[+T](value: T) {
  // We can't use 'def put(item: T)', so we use a lower bound
  def put[U >: T](item: U): Box[U] = Box(item)
}

val dogBox = Box("Fido") // Box[String]
val animalBox = dogBox.put("Generic Animal") // Now it's a Box[Any] (or whatever the common supertype is)

It's a bit of a brain-bender at first, but once you realize it's just a way to preserve type safety while maintaining flexibility, it clicks. You aren't changing the box; you're evolving the box's type to accommodate the new, broader value.




📋 Practical Task

Implementing a Variance-Safe Event Processor

You are building an event-driven system for a zoo. You have a class hierarchy: Event (base), AnimalEvent (extends Event), and FeedingEvent (extends AnimalEvent).

Your task is to implement the following:

  1. Create the Event hierarchy described above.
  2. Create a Processor[-T] trait with a method process(event: T): Unit. Ensure it is contravariant so that a Processor[Event] can be used wherever a Processor[FeedingEvent] is expected.
  3. Create a EventBuffer[+T] case class that holds a List[T].
  4. Inside EventBuffer, implement a method addEvent[U >: T](event: U): EventBuffer[U] that returns a new buffer containing the added event, using a lower bound to maintain covariance.
  5. In a main method, demonstrate that you can pass a Processor[Event] into a function that expects a Processor[FeedingEvent].
Rating
0 0

There are no comments for now.

to be the first to leave a comment.