Most type systems ask what a value is. Scala can also ask where it came from. If a class is defined inside another class, every instance of the outer class gets its own version of the inner type. A node created by graph g1 has type g1.Node, a node from g2 has type g2.Node, and the compiler refuses to mix them. The type depends on a path, a chain of stable references such as g1 or config.db, which is where the name comes from.

That lets a library state rules the compiler enforces: a cursor must be closed by the connection that opened it, and a value stored under a key must have that key's type. This article covers stable paths, type members, dependent methods, the Scala 2 and Scala 3 differences, API design and failure modes. Examples use Scala 3 syntax.

Advertisement

The core idea: one definition, many types

Start with the classic example. A Graph class defines a nested Node class. Because Node is a member of the instance, not of the companion, each graph has its own node type:

class Graph:
  class Node(val id: Int)
  private var edges = List.empty[(Node, Node)]
  def node(id: Int): Node = Node(id)
  def connect(a: Node, b: Node): Unit = edges = (a, b) :: edges

val g1 = Graph()
val g2 = Graph()
val a = g1.node(1)      // a: g1.Node
val b = g1.node(2)      // b: g1.Node
val c = g2.node(3)      // c: g2.Node

g1.connect(a, b)        // ok
// g1.connect(a, c)     // error: Found: (c : g2.Node)  Required: g1.Node

def describe(n: Graph#Node): String = s"node ${n.id}"   // accepts nodes from any graph

Three types are in play. g1.Node is the type of nodes that belong to g1. g2.Node is the type of nodes that belong to g2. Graph#Node, a type projection, is the type of a node from any graph, and both path-dependent types are subtypes of it. Methods inside Graph that mention Node are really talking about this.Node, so g1.connect demands g1.Node arguments.

In Java the inner class has a single type, Graph.Node, so nothing stops an edge between two graphs. Scala catches it at compile time at no runtime cost: the types are erased and nothing extra is stored in the objects.

One class definition, two distinct types: g1.Node and g2.Nodeval g1 = new Grapha: g1.Nodeb: g1.Nodeg1.connect(a, b) compilesboth arguments have type g1.Nodeval g2 = new Graphc: g2.Noded: g2.Nodeg2.connect(c, d) compilesboth arguments have type g2.Nodeg1.connect(a, c) is rejected at compile time:found g2.Node, required g1.NodeGraph#Nodethe projection: a node of ANY graphg1.Node and g2.Node are both subtypes of Graph#Node, but not of each other.The compiler tells them apart by the stable path (g1 or g2), not by any runtime tag.
The compiler tracks which instance a nested type came from. Same-path values combine; values from different paths are rejected unless the API asks for the projection Graph#Node.

Stable paths and singleton types

A path-dependent type only works if the compiler can prove that two mentions of a path refer to the same object. That is what a stable path is. A path is a chain of stable identifiers, starting from this, a package, an object, a val, a lazy val or a method parameter, and followed by members that are themselves stable. A var is not stable, because it might point at a different graph by the time the second mention is evaluated. A def is not stable either, because two calls could return two different objects.

val fixed = Graph()
var moving = Graph()

val n1: fixed.Node = fixed.node(1)     // ok: a val is a stable path
// val n2: moving.Node = ...           // error: moving is not a stable path (it is a var)

def addTwo(g: Graph): Unit =           // parameters are stable inside the method body
  g.connect(g.node(1), g.node(2))

val alias: fixed.type = fixed          // singleton type: the only value is fixed itself
val n4: alias.Node = n1                // ok: alias.type = fixed.type, so the paths agree

The last two lines introduce singleton types. For any stable path x, the type x.type has exactly one value, x itself. The compiler uses singleton types internally to reason about paths, and you can use them to say that a method returns the same object it was called on, the classic this.type return type in fluent builders.

Keep anything that acts as a path in a val. Turning it into a var or def breaks every type that mentioned it.

Advertisement

Abstract type members versus type parameters

Nested classes are one source of path-dependent types. The more common one in library code is the abstract type member: a trait declares type Value without defining it, and each implementation fixes it. k.Value then means the value type chosen by the particular key k. The same information could be written as a type parameter, Key[V], and choosing between the two is the first design decision.

QuestionType parameter Key[V]Type member Key { type Value }
Who chooses the type?The user of the type, at every mentionThe implementation, once
Mentions in signaturesMust be repeated everywhere: Key[V], Store[K, V]Hidden until needed: Key, then k.Value
InferenceStrong; unification solves VWeaker; often needs the Aux pattern
Best forContainers and functions over any VModules, plugins, protocols, families of types

A good heuristic: if a type is an input that users vary, make it a parameter. If it is an output that an implementation decides and users mostly carry around, make it a member.

Dependent method types and dependent function types

A dependent method type is a method whose result type mentions one of its parameters. Scala 3 extends the same idea to function values with dependent function types:

trait Key:
  type Value
  def name: String

object Port extends Key:
  type Value = Int
  val name = "port"

// Dependent method type: the result type mentions the parameter.
def defaultFor(k: Key)(using d: Defaults): k.Value = d.lookup(k)

// Scala 3 dependent function type: the same thing as a first-class value.
val read: (k: Key) => Option[k.Value] = k => store.get(k)

The signature def defaultFor(k: Key): k.Value says that the result type is whatever value type the argument key chose. Call it with Port and you get an Int. One method serves every key without casts at the call site and without a type parameter the caller has to supply.

Scala 2 could not express this for function values without wrapping it in a trait. Scala 3's (k: Key) => k.Value is a first-class function whose result type depends on its argument.

Worked example 1: a typed key/value store

Configuration maps, request attributes and plugin registries share one weakness: values are stored as Any and cast on the way out, so a mistyped write fails much later at a read. Dependent types close the gap. The key carries its value type, and both put and get are dependent on the key:

trait Key:
  type Value
  def name: String

final class TypedStore private (underlying: Map[Key, Any]):
  def get(k: Key): Option[k.Value] =
    underlying.get(k).map(_.asInstanceOf[k.Value])   // safe: put is the only writer
  def put(k: Key)(v: k.Value): TypedStore =
    TypedStore(underlying.updated(k, v))

object TypedStore:
  val empty = TypedStore(Map.empty)

object Port extends Key { type Value = Int;    val name = "port" }
object Host extends Key { type Value = String; val name = "host" }

val cfg = TypedStore.empty.put(Port)(8080).put(Host)("db.internal")
val p: Option[Int]    = cfg.get(Port)        // Port.Value = Int
val h: Option[String] = cfg.get(Host)
// cfg.put(Port)("8080")                     // error: Found: String  Required: Port.Value

There is still one cast, inside get, because the underlying map stores Any. The cast is safe for a reason the compiler can check elsewhere: put is the only way in, and it will only accept a k.Value for key k. This is the general shape of a dependent API. The unsafe operation is confined to one small, private place whose correctness follows from the public signatures, and every caller gets static checking.

Worked example 2: connection-scoped cursors

Nested classes are the right tool when a value only makes sense together with the instance that created it. A database cursor belongs to one connection. Closing it through a different connection is a bug that typically shows up as a leaked server-side resource and an error far from the cause:

final class Connection(url: String):
  final class Cursor private[Connection] (sql: String):
    def next(): Option[Row] = ???
  def open(sql: String): Cursor = Cursor(sql)
  def close(c: Cursor): Unit = ???      // only accepts cursors of THIS connection

def copyRows(src: Connection, dst: Connection): Unit =
  val cur = src.open("select * from t")  // cur: src.Cursor
  src.close(cur)                         // ok
  // dst.close(cur)                      // error: Found: src.Cursor  Required: dst.Cursor

The constructor of Cursor is private to Connection, so the only way to get a src.Cursor is from src.open. The mistake on the commented line is caught before the code ever runs. The same pattern fits transaction handles, arena allocations, per-request scopes, and anything else that must be returned to the object that issued it.

Refinements and the Aux pattern

Type members have a sharp edge: they disappear when a type is widened. If a method's declared return type is the plain trait Codec, the caller learns only that the result has some Wire type, not which one. A refinement type keeps the information: Codec { type Wire = Array[Byte] } is a codec whose Wire is known. Because writing refinements everywhere is noisy, libraries define a type alias, conventionally named Aux:

trait Codec:
  type Wire
  def encode(s: String): Wire

object Codec:
  type Aux[W] = Codec { type Wire = W }

  // Annotating the result as plain Codec would forget Wire = Array[Byte].
  val bytes: Aux[Array[Byte]] = new Codec:
    type Wire = Array[Byte]
    def encode(s: String) = s.getBytes("UTF-8")

// Aux turns the member into a parameter, so callers can constrain it or infer it.
def send[W](c: Codec.Aux[W], payload: String)(using sink: Sink[W]): Unit =
  sink.write(c.encode(payload))

The Aux alias converts the member back into a type parameter exactly where inference needs one. In send, the compiler can solve W from the codec argument and then look up a Sink[W] given instance. In Scala 2 a parameter's type could not depend on another parameter in the same list, which is why Aux became so common in generic-programming libraries.

Projections, Scala 3 and how the pieces fit

The projection T#A means the member A of some unspecified value of type T. Scala 2 allowed it on abstract types, which proved unsound: suitable bounds could prove false subtyping relations. Scala 3 restricts projections to concrete class types such as Graph#Node and rejects T#A when T is abstract, so migrating code must switch to a type parameter, a match type or an Aux alias.

Given instances can depend on paths too: using Show[k.Value] finds the instance for a key's value type. When a path the compiler needs goes out of scope, it widens to the nearest expressible supertype, so errors can surface later than expected.

Failure modes

  • Accidental widening. A helper declared to return Codec instead of Codec.Aux[W], or a val annotated with a broader type, discards the member. Downstream code then fails with a mismatch between c.Wire and a concrete type. Fix it at the declaration, not at the call site with a cast.
  • Unstable paths. A field changed from val to var, or a value now obtained through a method call, makes every type that mentioned it inexpressible. Keep anything that acts as a path in a val, and be careful when a refactor moves it.
  • Runtime checks are not path-aware. Types are erased, so a match against a type depending on an abstract member cannot be checked at run time and the compiler warns that it is unchecked. Treat that warning as an error, and carry an explicit tag value if you need runtime dispatch.
  • Serialization and identity. Path-dependent safety is about object identity: two graphs deserialized from the same bytes are still different paths. Persist plain IDs and rebuild typed values inside the owner.
  • Unreadable errors and overuse. Path-heavy error messages frustrate teams that did not design the API. Use dependent types at boundaries where they prevent real bugs, and give the types short aliases.

Trade-offs and operational guidance

Path-dependent types buy compile-time guarantees about ownership and pairing at zero run-time cost. They charge for it with harder inference, harder error messages, and coupling between a value's type and the variable that holds it, which makes refactoring and binary-compatible evolution of published traits trickier.

Weaker guarantees have simpler tools: opaque types for distinct zero-cost identifiers, phantom type parameters for states, or a runtime owner check for internal code. Reach for path dependence when the invariant is truly per-instance, the bug it prevents is expensive, and the API is used often enough to justify a careful design. Readers wanting the wider picture can go on to the Scala type system overview, see how givens interact with paths in implicit resolution, compare the zero-cost alternative in Scala 3 opaque types, follow Aux-heavy generic programming in Shapeless, and see why inference gives up around paths in Scala type inference.

What to do next

  1. Compile the Graph example and deliberately pass a node from the wrong graph. Read the error until the path notation is familiar.
  2. Find one place in your code that stores Any and casts on read, such as an attribute map, and rewrite it as the typed key store above.
  3. Find one handle that must be returned to its creator, such as a cursor, lease or transaction, and make it a nested class with a private constructor.
  4. Audit public methods that return traits with type members. Where callers need the member, return an Aux or refinement type instead.
  5. If you are migrating to Scala 3, search for projections on abstract types, the T#A form, and plan replacements before the compiler forces you to.
Key takeaway: A path-dependent type is a type that belongs to a particular object, written as a stable path followed by a member. It lets the compiler enforce ownership: nodes of one graph, cursors of one connection, values of one key. Keep paths stable with vals, prefer type members for types an implementation decides and parameters for types users vary, return Aux or refinement types so members are not lost, and avoid abstract projections, which Scala 3 rejects. Used at the right boundaries, it turns a class of runtime bugs into compile errors at no runtime cost.