Unexplained Scala compiler behavior (3.7.4)

Summary

This incident revolves around unexpected differences in how Scala 3 infers types when calling contains on Set. Although the API signatures appear straightforward, Scala’s type inference and literal typing rules create surprising behavior. The result: two expressions that look identical do not compile the same way.

Root Cause

The root cause is a combination of:

  • Literal type inference: Set(1, 2, 3) is inferred as Set[Int], but only in certain contexts.
  • Polymorphic vs. monomorphic contains:
    • Seq.contains is widened: def contains[A1 >: A](elem: A1): Boolean
    • Set.contains is strict: def contains(elem: A): Boolean
  • Contextual type inference:
    • In inline expressions, Scala may infer a wider element type (e.g., Int | String) before type-checking contains.
    • In val bindings, Scala commits to a concrete type earlier, preventing the same widening.

Why This Happens in Real Systems

Real compilers must balance type safety, performance, and ergonomics. This leads to:

  • Early vs. late type commitment depending on syntactic context
  • Literal widening rules that differ between inline expressions and stored values
  • Overloaded or polymorphic methods interacting with inferred union types
  • Collections with different variance and method signatures producing inconsistent behavior

These are not bugs—they are consequences of complex, interacting design choices.

Real-World Impact

This kind of behavior can cause:

  • Confusing compile errors that appear inconsistent
  • Unexpected widening of inferred types
  • Silent acceptance of obviously-wrong code (e.g., Set(1,2,3).contains("b"))
  • Hard-to-debug type mismatches when refactoring from inline expressions to val bindings

Example or Code (if necessary and relevant)

// Case A: Does not compile
Set[Int](1, 2, 3).contains("b")

// Case B: Compiles (type inferred as Set[Int | String])
Set(1, 2, 3).contains("b")

// Case C: Does not compile (type fixed as Set[Int])
val set = Set(1, 2, 3)
set.contains("b")

How Senior Engineers Fix It

Experienced engineers typically:

  • Force explicit types when inference becomes misleading:
    val set: Set[Int] = Set(1, 2, 3)
  • Avoid relying on inferred union types in collection literals
  • Use typed constructors (Set[Int](...)) to prevent accidental widening
  • Add compile-time checks or linters to catch suspicious contains calls
  • Refactor code to make type expectations explicit, especially in APIs

Why Juniors Miss It

Juniors often miss this issue because:

  • They assume type inference is consistent across all contexts
  • They expect literal collections to behave the same as typed ones
  • They do not yet recognize when Scala widens types (e.g., to unions)
  • They assume contains behaves uniformly across all collections
  • They rarely inspect the inferred type of intermediate expressions

The surprising behavior is not a lack of skill—it’s a natural consequence of Scala’s powerful but intricate type system.

Leave a Comment