Skip to Content
Course content

198: How the Scala Compiler Type-Checks Code

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

I've always found that the best way to understand the Scala compiler isn't by reading the specification, but by intentionally trying to break it. We often talk about "type safety" as a binary—either it compiles or it doesn't—but the process the compiler uses to get to that decision is actually a fascinating bit of detective work called type inference and constraint solving.

Throwing some types at the wall

Let's start with something that looks trivial. I'm going to write a small helper function that returns the "better" of two values. I won't provide any explicit types for the return value, because I want to see what the compiler does on its own.

def pickOne(a: Any, b: Any) = {
  if (Math.random() > 0.5) a else b
}

If I hover over pickOne in my IDE, the compiler tells me the return type is Any. That's boring. It's correct, but it's the most conservative guess possible. Now, let's make it generic and see if the compiler gets smarter.

def pickOne[T](a: T, b: T): T = {
  if (Math.random() > 0.5) a else b
}

Now the compiler is happy. It sees that both inputs are of type T and the output is T. This is simple unification. But here is where things get interesting: what happens when we pass it two different types that share a common ancestor?

The hunt for the Least Upper Bound

I'm going to set up a quick class hierarchy. I've got a Media trait, and two implementations: Image and Video.

trait Media
case class Image(url: String) extends Media
case class Video(url: String, duration: Int) extends Media

def pickOne[T](a: T, b: T): T = if (Math.random() > 0.5) a else b

val img = Image("cat.jpg")
val vid = Video("dog.mp4", 60)

val result = pickOne(img, vid) 
// What is the type of 'result' here?

If you check the type of result, it's Media. I didn't tell the compiler to use Media; it figured it out. This is the compiler performing a Least Upper Bound (LUB) calculation. It looks at Image and Video, climbs up the inheritance tree for both, and finds the first common point where they meet. In this case, that's Media.

But wait. What if I remove the Media trait? If Image and Video are just two unrelated case classes, the compiler still has to find a LUB. In Scala 3, it might move to Product or Any. It never gives up; it just keeps climbing until it hits the root of the universe.

When the constraints clash

Now, let's try to trip it up. I want a function that only works if the types provided can be compared. I'll use a context bound for this.

def findMax[T : Comparable](a: T, b: T): T = {
  if (a.compareTo(b) > 0) a else b
}

Wait, I just realized I'm using a simplified version of Comparable. Let's assume T must extend Comparable[T]. If I try to call findMax(10, "hello"), the compiler doesn't just say "Types don't match." It goes through a specific failure sequence:

  • It tries to find a T that satisfies both Int and String.
  • It finds the LUB, which is Any (or Serializable).
  • It then checks: "Does Any implement Comparable[Any]?"
  • The answer is no. Boom. Compile error.

The compiler isn't just checking if the types are "equal"; it's building a system of equations. It says: T must be a supertype of a, T must be a supertype of b, and T must satisfy the constraint C. If no such T exists in the known universe of your project, it throws its hands up and gives you an error message.

The cost of inference

You might wonder why we don't let the compiler infer everything. I once worked on a codebase where a chain of five generic methods resulted in a type so complex that the IDE lagged for three seconds just trying to show the tooltip. The inferred type looked like Option[Either[Error, Map[String, List[User]]]], but buried inside it were several internal compiler types like scala.collection.immutable.Map and specific witness types.

When the compiler has to solve too many constraints, it can either take a long time or, more commonly, it settles for a type that is "too wide" (like Any), which forces you to cast the result later. That's why, as a rule of thumb, I always explicitly type the return values of my public API methods. It stops the "inference ripple effect" from leaking into the rest of your application.




📋 Practical Task

Fixing the Broad LUB in a Payment Processor

You are building a payment system. You have a trait PaymentMethod and two classes: CreditCard and PayPal. You have a generic function processPayment[T](method: T) that is intended to return the payment method it just processed.

Currently, the code is written such that when you pass a CreditCard or PayPal object into a list and map them through processPayment, the compiler is inferring the resulting list as List[Any] or List[Product] because the processPayment function is too generic.

Your Task:

  1. Define the PaymentMethod trait and the CreditCard and PayPal case classes.
  2. Implement processPayment[T](method: T): T.
  3. Create a List containing both a CreditCard and a PayPal instance.
  4. Use .map(processPayment) on that list.
  5. Observe the inferred type of the resulting list.
  6. The Fix: Modify the signature of processPayment using a type constraint (e.g., T : PaymentMethod or T <: PaymentMethod) so that the compiler infers the resulting list as List[PaymentMethod] instead of List[Any].
Rating
0 0

There are no comments for now.

to be the first to leave a comment.