Skip to content

01 · OOP Deep Dive

Level 1 introduced traits as mixable contracts and gave you a first taste of stacking traits with super. This module goes deeper into what actually happens when several traits are mixed into one class — the order Scala resolves calls in, the abstract override pattern that makes stackable traits work, self-types for declaring "this trait needs a particular collaborator," and a closer look at how case classes and companion objects combine to build safe, well-modeled types.

Linearization: what order do mixed-in traits run in?

When a class mixes in multiple traits, Scala builds a single linear order (the linearization) that determines both which implementation "wins" and what super refers to inside each trait. The rule that matters day to day: the rightmost trait in the with chain is the first one whose override runs, and each trait's super call forwards to the next trait moving leftward — not to a fixed parent.

trait Greeter:
  def name: String
  def greet(): String = s"Hello, $name"

trait Loud extends Greeter:
  override def greet(): String = super.greet().toUpperCase + "!!!"

trait Polite extends Greeter:
  override def greet(): String = super.greet() + ", nice to meet you"

class Person(val name: String) extends Greeter with Loud with Polite
class Person2(val name: String) extends Greeter with Polite with Loud

println(Person("Ada").greet())
// HELLO, ADA!!!, nice to meet you
// Polite runs first (rightmost), calls super -> Loud, which calls super -> Greeter

println(Person2("Ada").greet())
// HELLO, ADA, NICE TO MEET YOU!!!
// Loud runs first (rightmost), calls super -> Polite, which calls super -> Greeter

The two classes mix in exactly the same traits, only in a different order, and get completely different behavior. This trips people up constantly: mixin order is not cosmetic — it changes which code runs first and what each super call sees.

abstract override: the pattern that makes stacking work

Notice Loud and Polite above only work as overrides of Greeter because Greeter.greet() has a real implementation to fall back on. If a trait wants to call super.someMethod() on a method that might not be implemented yet in any concrete class it's eventually mixed into, it must mark the override abstract override — a promise that "some trait or class further left in the chain will supply a real implementation before this runs."

trait Counter:
  private var count = 0
  def increment(): Int =
    count += 1
    count

trait LoggingCounter extends Counter:
  abstract override def increment(): Int =
    val result = super.increment()
    println(s"[log] incremented to $result")
    result

class RealCounter extends Counter with LoggingCounter

val rc = RealCounter()
rc.increment()   // [log] incremented to 1
rc.increment()   // [log] incremented to 2

LoggingCounter adds logging around whatever increment() it's stacked on top of, without knowing or caring what that implementation is. This is the "stackable trait" pattern: each trait in the chain contributes one slice of behavior, and with lets you compose them like middleware.

Self-types: declaring a required collaborator

A self-type lets a trait say "I can only be mixed into something that also has type X," without extending X itself. It's how you express a dependency between traits that shouldn't be tied together by inheritance.

trait HasEngine:
  def horsepower: Int

trait Drivable:
  self: HasEngine =>                       // Drivable requires a HasEngine
  def drive(): String = s"Driving with $horsepower hp"

class Car(val horsepower: Int) extends HasEngine with Drivable

println(Car(300).drive())   // Driving with 300 hp

Drivable can reference horsepower even though it doesn't define it, because the self-type annotation guarantees anything using Drivable also provides HasEngine. Try mixing Drivable into a class that doesn't extend HasEngine and it won't compile — the self-type is checked at compile time, not just documented in a comment.

Sealed hierarchies of case classes

Level 1 covered sealed trait with case objects for enum-like values. The same sealed mechanism works with case classes that carry data, which is the more common shape for real domain models:

sealed trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(width: Double, height: Double) extends Shape
case class Triangle(base: Double, height: Double) extends Shape

def area(s: Shape): Double = s match
  case Circle(r)        => Math.PI * r * r
  case Rectangle(w, h)  => w * h
  case Triangle(b, h)   => 0.5 * b * h
  // no case _ needed -- the compiler can verify all three subtypes are handled

val shapes: List[Shape] = List(Circle(2.0), Rectangle(3.0, 4.0), Triangle(5.0, 2.0))
shapes.foreach(s => println(f"${area(s)}%.2f"))
// 12.57
// 12.00
// 5.00

This sealed trait + case class combination is Scala's version of an algebraic data type (ADT) — it's the backbone of idiomatic Scala modeling, and you'll lean on it constantly in Module 2 · Advanced Pattern Matching and Module 4 · Option/Either.

Companion objects as smart constructors

Level 1 showed a companion object's apply replacing new. A step further: make the primary constructor private and route all construction through a companion method that validates the input and reports failure as data instead of an exception.

class Account private (val id: String, val balance: Double)

object Account:
  def open(id: String, initialDeposit: Double): Either[String, Account] =
    if initialDeposit < 0 then Left("Initial deposit cannot be negative")
    else if id.isBlank then Left("Account id cannot be blank")
    else Right(new Account(id, initialDeposit))

println(Account.open("acc-1", 100.0))   // Right(Account@...)
println(Account.open("acc-2", -5.0))    // Left(Initial deposit cannot be negative)

Because the constructor is private, the only way to get an Account from outside this file is Account.open, and that method can never return an invalid one. This "smart constructor" pattern shows up constantly once you've met Either properly in Module 4 — it's the idiomatic Scala alternative to throwing in a constructor.

How It Actually Works

Linearization is computed once, at compile time, using a deterministic algorithm (a variant of C3 linearization): for class C extends A with B with D, the compiler builds a single ordered list of every ancestor — right-most trait first, each trait's own ancestors inserted depth-first before it, with duplicates collapsed to their first (right-most) occurrence. That ordered list becomes the actual super call chain baked into the compiled methods: each trait's compiled bytecode contains a call not to "the next trait textually" but to the specific next class in this particular linearization for this particular class, which is why the same trait's super.speak() can dispatch differently depending on what it's mixed into. abstract override is what makes this legal: normally you can't call super.foo() on an abstract method, but abstract override tells the compiler "trust me, whatever gets linearized before me will have provided a concrete implementation by the time this runs" — a promise checked when the final concrete class is assembled, not when the trait itself is compiled.

Self-types (this: Repository =>) compile to nothing extra in bytecode at all — no field, no interface, no runtime check. They're a compile-time-only constraint: the compiler simply refuses to compile any concrete class mixing in this trait unless it also mixes in (or extends) something conforming to Repository. This is different from extends Repository, which would make the trait itself a Repository and bytecode-visible as such; a self-type only requires the combination at the final class to satisfy it, letting you write mutually-dependent traits (A requires B, B requires A) that neither could extend directly without a cycle.

Cheat sheet

Concept What it means
Linearization The order Scala flattens a with chain into for method dispatch
Rightmost trait wins In extends A with B with C, C's override is the entry point
super in a trait Forwards to the next trait leftward in the linearization, not a fixed parent
abstract override Marks an override that requires a real implementation supplied further down the chain
Self-type (self: T =>) Declares "this trait can only be used where T is also present," without extending T
sealed trait + case classes An algebraic data type — exhaustiveness-checked, data-carrying hierarchy
Private constructor + companion factory "Smart constructor" — the only path to a valid instance

Exercise

Model a small permissions system: sealed trait Role with case object Admin, case object Editor, and case object Viewer. Then build a stackable trait pair around a trait Repository with a single method def save(item: String): String (base implementation just returns s"saved: $item"). Write trait Auditing extends Repository using abstract override to prepend "[audit] " to whatever the wrapped save returns, and trait Validating extends Repository using abstract override to reject (throw new IllegalArgumentException) any blank item before delegating to super.save. Mix both into one class in two different orders and observe how the order changes whether validation or auditing "sees" the call first.