Dependent Types Primer in Scala
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