Scala 3's Type System, Part 9: Dependent, Literal, and Structural Types
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.
reflectiveSelectableis reflection; a hand-writtenSelectableis 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.