Dependent types are types that depend on values. In other words, they are types that can be computed from or selected based on a particular value (i.e. instance).

Scala 3 supports the following form of dependent types:

  • Path-dependent types
  • Dependent method types
  • Dependent function types

Path Dependent Types

class Window:
  class Button:
    def click(): Unit = {
      println("Clicked")
    }

  def add(button: Button): Unit = {
    println("Button added")
  }

val w1 = new Window
val b1 = new w1.Button
w1.add(b1) // OK

val w2 = new Window
// w2.add(b1) // Doesn't compile

Here, Button is the path-dependent type, which is different for each instance of Window. In other words, each Window has its own Button type. Therefore, b1 is not a valid button for w2.

This gives you the type safety and guarantee that you can’t accidentally add a button created for one window into another window.

Dependent Method Types

This is the case when the return type of a method can refer to one of its parameters. It is one of Scala’s mechanisms for expressing relationships between values and types.

Here is a classic example:

trait Key:
  type Value

case object Id extends Key:
  type Value = Int

case object Name extends Key:
  type Value = String

def lookup(k: Key): k.Value =
  k match
    case Name => "Hello"
    case Id   => 42

Note that the return type of lookup is k.Value. The type of the looked up value depends on the key k passed to the lookup method.

val name: String = lookup(Name)
val id: Int = lookup(Id)

With the above, we can create a Storage class that can lookup a key for its relevant typed value.

class Storage:
  private val data = Map[Key, Any](
    Name -> "Bob",
    Id -> 123
  )
  
  def apply(k: Key): f.Value =
    data(k).asInstanceOf[f.Value]

Usage:

val storage      = new Storage
val id: Int      = storage(Id)
val name: String = storage(Name)

Without such support for relationships between values and types, you would have to return Any and cast it to desired type at the call site without the type safety backed by the compiler.

Dependent Function Types

Methods aren’t first-class values; functions are. Scala 3 introduced dependent function types to express this dependency.

A dependent function type looks like:

(k: Key) => k.Value

Here is an example:

val lookup: (k: Key) => k.Value =
  (k: Key) => ???

It is similar to dependent method type that the return type varies with its argument.

Real World Use Case

Dependent types are widely used in popular open source libraries. Shapeless is one of the libraries that uses dependent types.

Take Generic for example:

trait Generic[A]:
  type Repr
  def to(a: A): Repr
  def from(r: Repr): A

Now let us consider a User type:

case class User(id: Int, name: String)

Then

val gen = Generic[User]
// gen.Repr is Int :: String :: HNil
// gen.to(user) returns gen.Repr

When to use dependent types

While typeclasses are an alternative to the kind of problems dependent types solve, dependent types are most useful when an object carries a type that should go with it providing the necessary type restriction. Here are some example:

Resource

trait Resource:
  type Handle
  def open(): Handle

Codec

trait Codec:
  type Value

  def encode(v: Value): Array[Byte]
  def decode(bytes: Array[Byte]): Value

Database

trait Schema:
  type Row
  def parse(raw: String): Row

These examples demonstrate that the return type depends on the instance of the specific parent type viz. Resource, Codec or Schema.

Hero image credit: Francesco Ungaro on Unsplash