A phantom type is a type parameter that appears in a type's signature but in none of its fields. A Conn[Open] and a Conn[Closed] hold exactly the same data; the parameter exists only so the compiler can tell them apart. Because nothing at runtime depends on it, the JVM erases it, and you get compile-time guarantees for zero runtime cost.

That one trick covers a lot of ground: protocols whose methods are only legal in certain states (typestate), builders that refuse to build until required fields are set, identifiers that cannot be mixed up, and quantities that cannot be added across units. This article builds each from first principles in Scala 3, explains how the compiler enforces them, and spends as much time on where phantom types stop protecting you, because that is where production bugs come from.

Advertisement

What makes a type phantom

Start with the smallest possible example. The marker types below have no values and never will; they are names the type checker can compare.

sealed trait DoorState
sealed trait Opened extends DoorState
sealed trait Shut   extends DoorState

final class Door[S <: DoorState] private (val id: String)

object Door:
  def make(id: String): Door[Shut] = new Door[Shut](id)

The class body never mentions S. At the bytecode level there is one class, Door, with one field. What changes is what the compiler will let you do with a value of each type. The private constructor matters: if anyone could write new Door[Opened]("x"), the state would be a claim rather than a fact. The companion is the only way in, and every method that changes state returns a value with a new phantom argument.

Two Scala features turn this into enforcement. Evidence parameters, (using S =:= Opened), ask the compiler to prove that the type argument is exactly Opened at the call site. Extension methods defined only on Door[Opened] make a method simply not exist for other states. Both are resolved during type checking; neither leaves a runtime test behind.

Typestate: a connection protocol the compiler enforces

Typestate with a phantom parameter: legal transitions are the only methods that compileConn[Closed]Conn(url)openConn[Open]socket upbeginConn[InTx]query allowedcommit / rollbackclosequery(sql) needs CanQuery[S]: only given for InTxRejected at compile timeConn[Closed].query, Conn[Open].commit, Conn[InTx].close -> 'No given instance' / custom messageAt runtime every box is the same class with the same fields: S is erased and costs nothing.What the types cannot stop: a stale Conn[Open] variable kept after close (see Pitfalls).
A database connection modelled as typestate. Each arrow is a method returning a value with a new phantom argument.

A realistic case is a connection with a transaction protocol: you must open before you begin, begin before you query, and commit or roll back before you close. Here is the whole thing.

sealed trait ConnState
object ConnState:
  sealed trait Closed extends ConnState
  sealed trait Open   extends ConnState
  sealed trait InTx   extends ConnState
import ConnState.*

final class Conn[S <: ConnState] private (raw: RawConn):
  def open(using S =:= Closed): Conn[Open] =
    raw.connect(); new Conn[Open](raw)
  def begin(using S =:= Open): Conn[InTx] =
    raw.exec("BEGIN"); new Conn[InTx](raw)
  def query(sql: String)(using CanQuery[S]): Rows =
    raw.query(sql)
  def commit(using S =:= InTx): Conn[Open] =
    raw.exec("COMMIT"); new Conn[Open](raw)
  def rollback(using S =:= InTx): Conn[Open] =
    raw.exec("ROLLBACK"); new Conn[Open](raw)
  def close(using S =:= Open): Conn[Closed] =
    raw.disconnect(); new Conn[Closed](raw)

object Conn:
  def apply(url: String): Conn[Closed] = new Conn[Closed](RawConn(url))

val rows =
  val c  = Conn("jdbc:postgresql://db/ledger").open.begin
  val rs = c.query("select * from accounts")
  c.commit.close
  rs

Call Conn(url).query(...) and compilation fails, because no CanQuery[Closed] exists. Call close inside a transaction and the compiler reports that it cannot prove InTx =:= Open. The protocol is now documented in the types, checked on every build, and visible in IDE completion.

Advertisement

Error messages people can act on

Raw =:= failures read like Cannot prove that ConnState.InTx =:= ConnState.Open, which is accurate but unhelpful to a colleague who has never seen the design. A custom evidence type lets you write the message yourself, and lets one capability cover several states.

import scala.annotation.implicitNotFound

@implicitNotFound("query needs an open transaction, but the connection is in state ${S}. Call begin first.")
sealed trait CanQuery[S]
object CanQuery:
  given CanQuery[InTx] = new CanQuery[InTx] {}

// Later, read-only snapshots can also query, without touching Conn:
// given CanQuery[ReadOnlySnapshot] = new CanQuery[ReadOnlySnapshot] {}

Sealing the trait means nobody outside this file can invent a given CanQuery[Closed] and switch the check off. The message interpolates the actual state, so the error tells the reader what to do. The alternative, extension methods on specific states, produces value query is not a member of Conn[Closed], which some teams find clearer still. Pick one style per API so errors are predictable.

Worked example: a builder that cannot build half a request

Builders are the second classic use. An HTTP request needs a method and a URL; headers are optional. A runtime builder throws on build() when something is missing. A phantom builder makes the incomplete call fail to compile.

sealed trait Flag
sealed trait Missing extends Flag
sealed trait Present extends Flag

final case class HttpRequest(method: String, url: String, headers: Map[String, String])

final class RequestBuilder[M <: Flag, U <: Flag] private (
    m0: Option[String], u0: Option[String], hs: Map[String, String]):
  def method(m: String): RequestBuilder[Present, U] = new RequestBuilder[Present, U](Some(m), u0, hs)
  def url(u: String): RequestBuilder[M, Present]    = new RequestBuilder[M, Present](m0, Some(u), hs)
  def header(k: String, v: String): RequestBuilder[M, U] =
    new RequestBuilder[M, U](m0, u0, hs + (k -> v))
  def build(using M =:= Present, U =:= Present): HttpRequest =
    HttpRequest(m0.get, u0.get, hs)   // safe: the flags prove both are Some

object RequestBuilder:
  def apply(): RequestBuilder[Missing, Missing] = new RequestBuilder(None, None, Map.empty)

val ok  = RequestBuilder().url("https://api/x").header("a", "b").method("GET").build
// val bad = RequestBuilder().url("https://api/x").build   // does not compile: Missing =:= Present

Order does not matter; each setter flips one flag. Notice the .get calls: they are safe only because the class is closed and every constructor call keeps the flags honest. The phantom proof is only as good as the code inside the private boundary, so keep that code short and test it. Past four or five required fields, the type signatures become noisy enough that a case class with required constructor parameters is usually the better tool.

Tagged identifiers with opaque types

Identifiers are the most common phantom type in real code. A Long user ID and a Long order ID are interchangeable to the compiler, which is how cancel(user.id) reaches production. Combining Scala 3 opaque types with a phantom parameter fixes it with no boxing.

object ids:
  opaque type Id[A] = Long
  object Id:
    def apply[A](raw: Long): Id[A] = raw
  extension [A](id: Id[A]) def value: Long = id

import ids.*
final case class User(id: Id[User], name: String)
final case class Order(id: Id[Order], owner: Id[User])

def cancel(order: Id[Order]): Unit = ()
// cancel(user.id)   // Found: Id[User]  Required: Id[Order]

Inside ids, Id[A] is just a Long. Outside it is an abstract, invariant type constructor, so Id[User] and Id[Order] never unify. The parameter A is phantom: no User is stored. The same pattern gives typed keys for caches, typed column references in query builders and tenant-scoped handles.

Units of measure

Units of measure follow the same shape. The phantom parameter records the unit; arithmetic is only defined between equal units, and derived units get their own phantom.

sealed trait Meters; sealed trait Seconds; sealed trait Per[A, B]

final case class Qty[U](value: Double) extends AnyVal:
  def +(o: Qty[U]): Qty[U] = Qty(value + o.value)
  def per[V](o: Qty[V]): Qty[Per[U, V]] = Qty(value / o.value)

val d = Qty[Meters](1200.0)
val t = Qty[Seconds](60.0)
val v: Qty[Per[Meters, Seconds]] = d.per(t)
// d + t   // Found: Qty[Seconds]  Required: Qty[Meters]

This catches the bug class that matters, adding seconds to milliseconds or metres to feet, without a units library. It does not do dimensional algebra, so Per[Meters, Seconds] and an equivalent expression built another way are different types. If you need real algebra, use a dedicated library; if you need the 90 percent case, this is twenty lines.

Pitfalls: where the guarantee stops

Stale references survive transitions. Phantom types are not linear types. Nothing stops you from keeping the old value:

val c0 = Conn("jdbc:postgresql://db/ledger")  // Conn[Closed]
val c1 = c0.open                               // Conn[Open]
val c2 = c1.close                              // Conn[Closed]
c1.begin                                       // compiles: c1 is still typed Conn[Open]

The type of c1 did not change when the socket closed. Mitigations: expose a scoped API such as Conn.withTransaction(url)(c => ...) so user code never holds a connection across a transition; keep a cheap runtime state check inside RawConn as a backstop; and treat the phantom as a guide rail, not a proof. Scala 3's capture checking is experimental research in this direction and not something to depend on for production APIs.

Variance and Nothing. Declare the parameter invariant and use =:=, not <:<. Nothing is a subtype of every state, so Nothing <:< Open and Nothing <:< InTx both hold, and a Conn[Nothing] would satisfy every constraint at once. A generic factory such as def load[S <: ConnState](...): Conn[S] called without an explicit type argument can infer exactly that. Do not write such factories.

Erasure. A pattern case c: Conn[Open] => compiles with an unchecked warning and matches every Conn. If you need to recover a state at runtime, model it explicitly.

Boundaries. JSON, databases and message queues do not carry types. Decoding must produce a value whose state is checked, not assumed:

enum AnyConn:
  case IsClosed(c: Conn[Closed])
  case IsOpen(c: Conn[Open])

def restore(snapshot: Snapshot): Either[String, AnyConn] = ...

Signature growth. Every phantom parameter appears in every signature that passes the value around. Libraries that thread three or four of them produce error messages nobody reads. Hide them behind type aliases such as type ReadyRequest = RequestBuilder[Present, Present] and keep the parameter count low.

Trade-offs and related tools

ApproachCatches misuseRuntime costErgonomicsUse when
Runtime state checkIn tests or productionA branch per callSimpleState is dynamic or crosses a boundary
One class per state (sealed ADT)Compile timeAllocation per transitionClear errors, duplicated methodsFew states, different data per state
Phantom parameter + evidenceCompile timeNoneConcise; errors need careSame data across states, protocol-heavy APIs
Opaque type + phantom tagCompile timeNone, no boxingExcellentIDs, keys, handles
Refinement types (Iron)Compile time for literals, validated at edgesValidation at constructionGoodValue predicates such as positive or non-empty

Phantom types encode facts about how a value was obtained, not facts about its contents. If the invariant is about the content, such as an email being well formed, reach for refinement types with Iron. If the distinction is purely nominal, start from opaque types. If the type must depend on a particular value, read path-dependent types. Evidence resolution is the same machinery explained in givens and using clauses.

Scala 2 supports everything here except opaque types: use (implicit ev: S =:= Open) and value classes or tagged types for identifiers. Scala 3 also has an experimental erased modifier that removes evidence arguments from the bytecode; it requires an experimental language import, so do not rely on it in libraries.

What to do next

  1. Grep your codebase for pairs of methods that throw IllegalStateException when called in the wrong order; each is a typestate candidate.
  2. Pick the one with the most incidents and model it with invariant phantom states, a private constructor and =:= or sealed custom evidence.
  3. Add @implicitNotFound messages that name the state and the fix, and check them with a compile-failure test (scala.compiletime.testing.typeCheckErrors in Scala 3).
  4. Wrap transitions in a scoped API so callers cannot hold a value across a state change, and keep a runtime check underneath.
  5. Replace raw Long and String identifiers on your busiest service boundary with Id[A] and fix the compile errors; each one is a latent mix-up.
  6. Decode external data into a sealed wrapper of states, never directly into an assumed phantom type.
Key takeaway: A phantom type parameter lets the compiler distinguish values that hold identical data, at zero runtime cost. Use it for typestate protocols, builders with required fields, tagged identifiers and units. Keep the parameter invariant, prove states with =:= or sealed custom evidence with readable messages, and guard the constructor. Remember the limits: stale references keep their old state, erasure hides the parameter at runtime, and external data must be decoded into explicitly checked states.