Scala 3's Type System, Part 1: Why It Was Rebuilt
Scala 2’s type system had holes that were not exotic — you could hit one by declaring two traits with the same method name and mixing them in the wrong order. Scala 3 did not patch those holes; it replaced the core with something that has a machine-checked soundness proof and rebuilt the surface syntax on top of it. This series is about what that actually buys you at the keyboard.
Every code block in this series is compiled before publishing, against both Scala 3.7 and 3.8, using the standard library only — no external dependencies.
🔗The problem: order-dependent intersections
Here are two traits that both happen to declare read(). This is not a
contrived situation: it happens the moment two independently-designed interfaces
converge on an obvious name.
trait Reader:
def read(): Seq[String]
trait CsvSource:
def read(): List[String]
In Scala 2, Reader with CsvSource and CsvSource with Reader are different
types. The member type of read() is resolved by linearization, so the last
trait wins:
// scala-check: skip
// Scala 2
def a(s: Reader with CsvSource): List[String] = s.read() // compiles
def b(s: CsvSource with Reader): List[String] = s.read() // type error
The second one fails because read() there is typed Seq[String]. Two
signatures that any reader would call equivalent behave differently, and which
one you get depends on the order somebody typed the traits. Worse, Scala 2’s
with was not just non-commutative — it was one of the constructs implicated in
the soundness counterexamples that circulated for years.
Scala 3 replaces it with &, a genuine set-theoretic intersection. It is
commutative, and the member type is the intersection of the member types:
trait Reader:
def read(): Seq[String]
trait CsvSource:
def read(): List[String]
// Both compile. `read()` is typed Seq[String] & List[String] either way,
// which is a subtype of List[String].
def a(s: Reader & CsvSource): List[String] = s.read()
def b(s: CsvSource & Reader): List[String] = s.read()
A with B still parses in Scala 3 — it is an alias for A & B — but it is on
the way out, and the meaning has changed underneath you. That is the shape of
most of Scala 3’s type changes: the same idea, given a definition that actually
holds up.
&is commutative for typing. It is not commutative for initialization —class C extends A, Bstill linearizes, and whichread()implementation you inherit still depends on order. Types and linearization are separate concerns in Scala 3, which is precisely the point.
🔗DOT, briefly
The reason this could be fixed at all is that Scala 3 has a theory underneath it. DOT — Dependent Object Types — is a small calculus with path-dependent types, type members, and subtyping, developed over roughly a decade by Amin, Rompf, Odersky and others. It has machine-checked soundness proofs.1
What that means practically:
- Path-dependent types are primitive, not a special case bolted onto the
object model.
config.Keyis a first-class type, and the rules for whena.T <: b.Tare the calculus’s rules. - Union and intersection types are real type operators with subtyping laws, rather than syntax that happened to work.
- Type members and type parameters are unified in the core, which is why match types and type lambdas can exist at all.
Be honest about the limits: DOT is a core calculus. Scala 3 the language is much larger, and “the core is proven sound” is not “the compiler has no soundness bugs.” What it does mean is that when a soundness bug is found now, there is a reference to check the answer against, instead of an argument.
🔗Three jobs a type system does
The rest of this series is organized around three things you actually use a type system for.
(a) Modeling data so illegal states can’t be built. An Order that is both
cancelled and shipped should not be constructible. This is what algebraic
data types, opaque types, and unions are for — Part 2,
Part 3, and
Part 4.
(b) Abstracting over behavior. Writing one function that works for every type that can be serialized, compared, or combined — without inheritance, and without runtime cost. That is type classes and the machinery around them: Part 5, Part 6, and Part 7.
(c) Computing at compile time. Types that are calculated from other types, values lifted into types, and structure checked before the program runs: Part 8, Part 9, and Part 10.
Job (a) pays off immediately. Job (b) pays off on the third caller. Job (c) pays off in libraries and costs you compile time — I will be specific about that cost when we get there.
🔗One keyword, five jobs
The other half of the redesign is not about soundness at all — it is about the
fact that Scala 2 used implicit for five unrelated things:
// scala-check: skip
// Scala 2 — five different features, one keyword
implicit val show: Show[Money] // 1. provide an instance
implicit def showList[A](implicit s: Show[A]): Show[List[A]] // 2. derive an instance
def render[A](a: A)(implicit s: Show[A]): String // 3. require an instance
implicit class OrderOps(o: Order) { def isFree: Boolean } // 4. add a method
implicit def strToMoney(s: String): Money // 5. convert silently
Reading Scala 2 code meant inferring which of the five was meant from context. It also meant that (5) — the one everybody agrees is dangerous — was spelled almost identically to (1), the one everybody uses constantly. Scala 3 gives each job its own syntax:
final case class Money(cents: Long)
final case class Order(id: String, total: Money)
trait Show[A]:
def show(a: A): String
// 1. provide an instance
given Show[Money] = m => s"${m.cents / 100}.${m.cents % 100}"
// 2. derive an instance from another
given [A](using sa: Show[A]): Show[List[A]] =
as => as.map(sa.show).mkString("[", ", ", "]")
// 3. require an instance
def render[A](a: A)(using s: Show[A]): String = s.show(a)
// 4. add a method
extension (o: Order)
def isFree: Boolean = o.total.cents == 0
@main def demo(): Unit =
println(render(List(Money(1250), Money(0))))
println(Order("A-1", Money(0)).isFree)
Conversions — job (5) — now require you to define a Conversion[From, To]
instance and for the call site to enable scala.language.implicitConversions.
That is deliberate friction. An accidental implicit def that silently reshapes
your program is much harder to write by mistake now.
There is a real cost to the split: given/using syntax has itself churned
across 3.x releases. The given Foo with form, the given [A](using ...) form,
and the newer given [A] => Foo[A] => Foo[List[A]] form all exist in the wild.
Code written against 3.0 may emit deprecation warnings under a recent compiler.
Pick one style per codebase and pin your compiler version.
🔗The migration reality
Scala 3 reads Scala 2.13 class files and vice versa for most cases, so mixed builds work and you can migrate a module at a time. That is genuinely good, and it is why the ecosystem moved. What it does not mean:
- Macros do not port. Scala 2 macros have to be rewritten against the new metaprogramming API. If your dependency graph has an unmaintained macro library in it, that is your blocker, not the syntax.
-source:3.0-migrationplus-rewritewill mechanically fix most syntax, but it will not fix implicit resolution differences, and those are the ones that produce a working program with different behavior.- Type inference changed. Mostly for the better, occasionally in ways that make a previously-inferred type more specific and break a downstream call.
None of this is a reason to avoid Scala 3. It is a reason to budget the migration in weeks rather than an afternoon.
🔗The series
- Why It Was Rebuilt — this post
- Enums and ADTs
- Union and Intersection Types
- Opaque Types
- Given, Using, and Type Classes
- Type Class Derivation
- Higher-Kinded Types and Type Lambdas
- Match Types and Compile-Time Computation
- Dependent, Literal, and Structural Types
- GADTs, Variance, and the Frontier
🔗Next up
Part 2 starts with job (a): taking a domain currently modeled with strings and booleans, and making the invalid combinations fail to compile. It is the change with the best effort-to-payoff ratio in the whole language.
-
The main references are “The Essence of Dependent Object Types” (Amin, Grütter, Odersky, Rompf, Stucki, 2016) and the Coq/Twelf mechanizations that accompany the DOT line of work. ↩