1. Overview

Intersection Types have been added as built-in types in Scala 3.

In this tutorial, we’ll understand how to use them to define our class hierarchy.

2. Intersection Types

Intersection Types are composite data types. They are used to represent values that are of different data types at the same time. The & operator is used to create intersection types.

Let’s write together our first intersection types example:

trait Scissors:
  def cut: Unit

trait Needle:
  def sew: Unit

def fixDressOne(dressFixer: Scissors & Needle) =
  dressFixer.cut
  dressFixer.sew

We defined two types, Scissors and Needle, with a method each. In addition, we added a method taking the intersection of the two as a parameter: Scissors & Needle. Now, we can create an instance of an object which is both the types and try to use it:

object DressFixer extends Scissors, Needle {
  override def cut = println("Cutting dress")

  override def sew = println("Sewing dress")
}

println(fixDressOne(DressFixer)) // prints "Cutting dress Sewing dress "

It’s important to notice that intersection types are commutative. We can show this by writing a second method with an inverted signature:

def fixDressTwo(dressFixer: Needle & Scissors) =
  dressFixer.cut
  dressFixer.sew

println(fixDressTwo(DressFixer)) // prints "Cutting dress Sewing dress "

The fixDressTwo method can receive our DressFixer object with no compilation errors. Scissors & Needle is the same type as Needle & Scissors.

2.1. Overridden Methods Order Invocation

We’ve seen that intersection types are commutative. However, does the type ordering affect the behavior of the types somehow?

Let’s come up with an example:

trait OneGenerator:
  def generate: Int = 1

trait TwoGenerator:
  def generate: Int = 2

object NumberGenerator21 extends OneGenerator, TwoGenerator {
  override def generate = super.generate
}

object NumberGenerator12 extends TwoGenerator, OneGenerator {
  override def generate = super.generate
}

def generateNumbers(generator: OneGenerator & TwoGenerator) =
  generator.generate

What’d be the result of generateNumbers(NumberGenerator21) and generateNumbers(NumberGenerator12)?

Let’s write some tests to find out:

class BasicIntersectionTypeTest extends AnyWordSpec with Matchers {
  "Intersection Types" should {
    "use linearization to decide method override" in {
      generateNumbers(NumberGenerator21) shouldBe (2)
      generateNumbers(NumberGenerator12) shouldBe (1)
    }
  }
}

Despite OneGenerator & TwoGenerator being the same type as TwoGenerator & OneGenerator, the behavior of the overridden method might differ. Linearization, the right-to-left ordering, determines which overridden method to call.

3. Alternatives to Intersection Types

Intersection types are a concise way of defining an object that is more than one data type. However, there are other ways of doing this in Scala.

We’re going to rewrite the same code using different constructs and patterns and analyze the differences.

3.1. Duck Typing

Let’s start creating another example using intersection types:

trait Scissors:
  def cut: Unit
trait Knife:
  def cut: Unit
trait Chainsaw:
  def cut: Unit

object PaperCutter extends Knife, Scissors {
  override def cut = print("Cutting stuff")
}

def cutPaper(pc: Knife & Scissors) =
  pc.cut

Knives and scissors are definitively more suited to cut a sheet of paper, and this is easy to model with intersection types.

We now reimplement the same with Duck Typing:

class Scissors() {
  def cut(): Unit = { println("Cutting with Scissors") }
  val canCutPaper: Boolean = true
}

class Knife() {
  def cut(): Unit = { println("Cutting with Knife") }
  val canCutPaper: Boolean = true
}

class Chainsaw() {
  def cut(): Unit = { println("Cutting with Chainsaw") }
}

type PaperCutter = {
  val canCutPaper: Boolean
  def cut(): Unit
}

def cutPaper(pc: PaperCutter) =
  pc.cut()

Even with this simple example, we can clearly see how much more boilerplate we need. We had to introduce a new type with a field, canCutPaper, to distinguish among classes that can and cannot perform an action.

We’re increasing the size of our classes with logic that doesn’t necessarily belong there.

3.2. Overloading

Overloading will require us to implement the same method for each type we’re adding. Here, we can implement the example of the previous paragraph using overloading:

class Knife()
class Scissors()

def cutPaper(cutter: Knife) = println("Cutting with Knife")
def cutPaper(cutter: Scissors) = println("Cutting with Scissors")

Each time we want to add a new type, we’ll have to add a cutPaper method for it. This clearly requires more boilerplate and code duplication than intersection types.

3.3. Inheritance

We could achieve the same goal of the original example using inheritance. However, we’ll have to add an additional helper trait.

Let’s implement it to see what it looks like:

trait Scissors:
  def cut: Unit

trait Needle:
  def sew: Unit

trait Tools extends Scissors, Needle

object DressFixer extends Tools {
  override def cut = print("Cutting dress ")
  override def sew = print("Sewing dress ")
}

def fixDress(dressFixer: Tools) =
  dressFixer.cut
  dressFixer.sew

The addition of an extra class in the hierarchy, in larger codebases, isn’t always justified. Furthermore, it might not even be possible to add it when using external libraries.

4. Conclusions

In this tutorial, we’ve learned how to use intersection types to shape our data model.

We also saw alternatives to this built-in data type, showing how intersection types are yet a new tool provided by Scala 3.

The code can be found, as always, over on GitHub.
Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.