Scala 3's Type System, Part 9: Dependent, Literal, and Structural Types

scalatype-systems

A config map hands you back Any and every call site adds a cast. A Session hands you a token that type-checks perfectly against a different session’s validator. Both are the same gap: the type of a result depends on a value, and the type system has no way to say so.

🔗The problem: Any in, casts out

Here is the shape almost every configuration or record store starts with.

final class Config(entries: Map[String, Any]):
  def get(key: String): Any = entries(key)

@main def demo(): Unit =
  val config = Config(Map("port" -> 8080, "host" -> "localhost", "debug" -> true))
  val port = config.get("port").asInstanceOf[Int]
  val host = config.get("host").asInstanceOf[String]
  println(s"$host:$port")

This compiles, runs, and is wrong in an interesting way: the cast at each call site is a claim the compiler cannot check, and config.get("prot") fails at runtime rather than at the keyboard. The information needed to type this correctly is right there in the source — the string "port" is a literal. The compiler just has no way to use it.

🔗Literal types: a value the compiler can read

In Scala 3 every literal is also a type inhabited by exactly that one value.

val answer: 42 = 42
val portKey: "port" = "port"
val yes: true = true

On its own that is a curiosity. It becomes useful when a type parameter is allowed to be a literal type, which requires the Singleton upper bound — otherwise inference widens "port" to String immediately. Once the parameter is a literal type, scala.compiletime.constValue reads the value back out at compile time.

import scala.compiletime.constValue

inline def nameOf[K <: String & Singleton](key: K): String = constValue[K]

@main def demo(): Unit =
  println(nameOf("port"))  // the string is produced at compile time

Drop the & Singleton and the compiler widens K to String, which has no value to read:

-- Error: ------------------------------------------------------------
4 |inline def nameOf[K <: String](key: K): String = constValue[K]
  |                                                 ^^^^^^^^^^^^
  |         String is not a constant type; cannot take constValue
// scala-check: expect-error
import scala.compiletime.constValue

inline def nameOf[K <: String](key: K): String = constValue[K]

@main def demo(): Unit = println(nameOf("port"))

valueOf[T] from Predef does the same job for non-inline contexts, backed by a ValueOf[T] given that the compiler synthesizes for singleton types.

🔗Path-dependent types: two sessions, two token types

A type member declared inside a class is a different type per instance. That is the whole feature, and it is worth more than it sounds.

final class Session(val user: String):
  final case class Token(value: String)

  def issue(v: String): Token = Token(v)
  def validate(t: Token): Boolean = t.value.nonEmpty

val alice = Session("alice")
val bob = Session("bob")

@main def demo(): Unit =
  println(alice.validate(alice.issue("abc")))

Inside the class, validate(t: Token) means t: this.Token. So handing Bob’s validator a token minted by Alice’s session is a type error, not a security incident found in production:

// scala-check: expect-error
final class Session(val user: String):
  final case class Token(value: String)
  def issue(v: String): Token = Token(v)
  def validate(t: Token): Boolean = t.value.nonEmpty

val alice = Session("alice")
val bob = Session("bob")

@main def demo(): Unit =
  println(bob.validate(alice.issue("abc")))
-- [E007] Type Mismatch Error ------------------------------------------
11 |  println(bob.validate(alice.issue("abc")))
   |                       ^^^^^^^^^^^^^^^^^^
   |                       Found:    alice.Token
   |                       Required: bob.Token

The same trick prevents mixing nodes from two Graph instances, rows from two result sets, or handles from two connection pools. It costs one nested class.

🔗Dependent method and function types

If a type member can vary per instance, a method’s result type can name it.

trait Entry:
  type Value
  def value: Value

// Dependent method type: the result type mentions the parameter.
def read(e: Entry): e.Value = e.value

Scala 2 had that much. What it could not do was put such a function in a val, pass it around, or store it in a list — there was no type to write down. Scala 3 has dependent function types:

trait Entry:
  type Value
  def value: Value

def read(e: Entry): e.Value = e.value

// New in Scala 3: a first-class value with a dependent type.
val readF: (e: Entry) => e.Value = e => e.value

(e: Entry) => e.Value desugars to a refinement of Function1 whose apply is a dependent method. It is a normal value; you can map with it.

🔗Refinements: attaching static facts to a type

Entry { type Value = Int } is Entry plus the static knowledge that its Value is Int. Returning the refined type is what makes the dependent machinery usable at call sites.

trait Entry:
  type Value
  def value: Value

def read(e: Entry): e.Value = e.value

def intEntry(i: Int): Entry { type Value = Int } =
  new Entry:
    type Value = Int
    def value: Int = i

@main def demo(): Unit =
  val n: Int = read(intEntry(3))   // no cast, no Any
  println(n + 1)

Without the refinement in the return type, read(intEntry(3)) would be typed Entry#Value — abstract, and useless.

🔗Structural types: dot syntax over dynamic data

Everything above assumes you know the shape at compile time. Sometimes you genuinely do not: a JSON blob, a JDBC row, a CSV header. Structural types let a type describe members that no class declares.

import reflect.Selectable.reflectiveSelectable

def describe(x: { def name: String }): String = s"<${x.name}>"

class User(val name: String)

@main def demo(): Unit =
  println(describe(User("ada")))

That import is not decoration. It supplies the Selectable instance that implements x.name via Java reflection, on every call. If you want dot syntax over dynamic data without reflection, implement Selectable yourself:

class Record(elems: (String, Any)*) extends Selectable:
  private val fields = elems.toMap
  def selectDynamic(name: String): Any = fields(name)

type Person = Record { val name: String; val age: Int }

@main def demo(): Unit =
  val person = Record("name" -> "Ada", "age" -> 36).asInstanceOf[Person]
  println(s"${person.name} is ${person.age}")

person.name compiles to person.selectDynamic("name").asInstanceOf[String]. That is a hash lookup plus a cast — much cheaper than reflection, but note what the asInstanceOf[Person] at construction is doing: you asserted the shape. Structural types check that call sites are consistent with the declared shape; nothing checks that the shape matches the data. Feed a Record from a JSON parser and a missing key is still a runtime failure.

Structural access is the only feature in this series that costs you at runtime. reflectiveSelectable is reflection; a hand-written Selectable is a map lookup and a cast. Neither inlines to a field read. Use it at the edges where data is genuinely dynamic, and convert to real case classes inside.

🔗Worked example: a typed config reader

Now combine the pieces. Keys are literal types, and the value type is computed per key with a match type (Part 8).

import scala.compiletime.constValue

type ValueOfKey[K <: String] = K match
  case "port"  => Int
  case "host"  => String
  case "debug" => Boolean

final class Config(private val raw: Map[String, Any]):
  inline def get[K <: String & Singleton](key: K): ValueOfKey[K] =
    raw(constValue[K]).asInstanceOf[ValueOfKey[K]]

@main def demo(): Unit =
  val config = Config(Map("port" -> 8080, "host" -> "localhost", "debug" -> true))
  val port: Int = config.get("port")
  val host: String = config.get("host")
  val debug: Boolean = config.get("debug")
  println(s"$host:$port debug=$debug")

One cast survives, but it moved: it lives once inside the library instead of once per call site, and it is justified by the match type sitting next to it. Callers write config.get("port") and get an Int. config.get("prot") fails to reduce the match type and is a compile error.

🔗Gotchas

Literal types widen the moment you bind them to a val.

// scala-check: expect-error
def idOf[T <: Singleton](t: T): T = t

@main def demo(): Unit =
  val c = idOf(42)      // inferred as Int, not 42 — vals widen
  val w: 42 = c         // error: Found (c : Int), Required 42
  println(w)

The fix is an ascription (val c: 42 = idOf(42)) or passing the literal straight through without an intermediate binding. This surprises everyone once.

A String-typed variable is not a key. The match type can only reduce when it sees a literal:

// scala-check: expect-error
import scala.compiletime.constValue

type ValueOfKey[K <: String] = K match
  case "port" => Int
  case "host" => String

final class Config(private val raw: Map[String, Any]):
  inline def get[K <: String & Singleton](key: K): ValueOfKey[K] =
    raw(constValue[K]).asInstanceOf[ValueOfKey[K]]

@main def demo(): Unit =
  val config = Config(Map("port" -> 8080))
  val key = "port"                      // widened to String
  val port: Int = config.get(key)
Found:    ValueOfKey[(key : String)]
Required: Int

Note: a match type could not be fully reduced:
  trying to reduce  ValueOfKey[(key : String)]
  failed since selector (key : String)
  does not match  case ("port" : String) => Int
  and cannot be shown to be disjoint from it either.

This is the real ceiling on the technique. Keys read from a file, a CLI flag, or an environment variable are Strings. Compile-time key checking only works for keys written in the source, which covers “read known settings” and does not cover “iterate over whatever the user supplied.”

Path-dependent types produce long errors. When inference has to decide whether a.Token and b.Token are the same type it will tell you at length, often with the prefix path spelled out through several enclosing objects. Keeping the nested type shallow, and naming intermediate values so the paths are short, makes those messages readable.

🔗Next up

Part 10 closes the series with the deep end: variance and why covariant mutable containers are unsound, GADTs and the typed expression evaluator, and an honest survey of what is still experimental.