Opaque Types & Value Classes

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-15). Where the behaviour was also compiled with the 3.9 LTS line (3.9.0) and the 3.3 LTS line (3.3.8), the text says so. Opaque types and value classes solve the same problem: a distance in kilometres, a duration in minutes and a trail identifier are all "just a number", yet passing a duration where a distance is expected is a bug that the compiler should catch. Wrapping each number in a class fixes the typing but costs an allocation per value. This page shows the two ways Scala removes that cost, a value class (a class whose wrapper the compiler erases most of the time) and an opaque type alias (a type that is a plain alias inside its defining scope and a brand-new type everywhere else), and explains why the second is the better tool in Scala 3. The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails. For the type aliases and generics these types build on see Generics & Bounds; for the methods that give an opaque type its API see Extension Methods; for the type hierarchy that makes boxing necessary see Type Hierarchy & Matchable; for the wrapper classes that opaque types replace see Case Classes.

The problem: a number is not a unit

Two methods that both take a Double accept each other’s arguments, and the compiler cannot tell a kilometre from a metre or a trail identifier from a shelter identifier. The traditional answers are a naming convention or a wrapper class such as case class Km(value: Double). A wrapper is safe, but every Km is a heap object with a header, and a List[Km] or an Array[Km] is an array of references to those objects rather than a contiguous block of doubles. Value classes (Scala 2.10) and opaque types (Scala 3.0) remove that cost to different degrees.

Opaque type aliases

An opaque type alias is declared with opaque type and a right-hand side. Inside the scope where it is defined the alias is transparent: Km and Double are interchangeable. Outside that scope the alias is opaque: Km is an abstract type that the compiler knows nothing about, so it is not a Double, has none of the methods of Double, and no Double converts to it implicitly. The scope of the alias is the enclosing object, class or trait. Because the representation is hidden, the defining scope has to supply every way to make a value (a constructor in the companion) and every operation on it.

The example defines a distance and a duration in an object, builds them through the companion objects and gives them behaviour through extension methods.

//> using scala 3.10.0

package trailhead

object units:
  opaque type Km = Double
  opaque type Minutes = Int

  object Km:
    def apply(value: Double): Km = value
  object Minutes:
    def apply(value: Int): Minutes = value

  extension (d: Km)
    def +(other: Km): Km = d + other
    def toDouble: Double = d
    def show: String = s"$d km"
  extension (m: Minutes)
    def asInt: Int = m

import units.*

@main def run(): Unit =
  val a = Km(3.5)
  val b = Km(2.0)
  println((a + b).show)
  println(Minutes(90).asInt)
  println((a + b).toDouble)
5.5 km
90
5.5

Inside units, d + other in the extension body is a Double addition because there Km is Double. Outside, the + on Km is the extension method and nothing else. The companion object of an opaque type is the place for factories (and, as the next section shows, for instances such as an Ordering), and it is part of the implicit scope of the type.

What the outside sees

Outside the defining scope the compiler keeps the two types apart in both directions. The example tries to assign a Km to a Double and to add a plain number to a Km.

//> using scala 3.10.0

package trailhead

object units:
  opaque type Km = Double
  object Km:
    def apply(value: Double): Km = value

import units.*

@main def run(): Unit =
  val a = Km(3.5)
  val d: Double = a
  println(a + 1.0)
  println(a.toString)

Real compiler output (3.10.0), trimmed to the two errors:

[error] ./a2.scala:14:19
[error] Found:    (a : trailhead.units.Km)
[error] Required: Double
[error]   val d: Double = a
[error]                   ^
[error] ./a2.scala:15:11
[error] value + is not a member of trailhead.units.Km, but could be made available as an extension method.
[error]   println(a + 1.0)
[error]           ^^^

a.toString compiles because every type is a subtype of Any, which supplies toString, equals and hashCode; those members run on the underlying value, so a.toString prints 3.5 with no unit. Define an extension method with a different name (here show) when you want a different text.

Two opaque types over the same representation are also unrelated to each other, which is the point for identifiers.

//> using scala 3.10.0
package trailhead
object ids:
  opaque type TrailId = Int
  object TrailId:
    def apply(n: Int): TrailId = n
  opaque type ShelterId = Int
  object ShelterId:
    def apply(n: Int): ShelterId = n
import ids.*
def visit(t: TrailId): String = s"trail"
@main def run(): Unit =
  println(visit(TrailId(1)))
  println(visit(ShelterId(1)))
[error] ./b6.scala:14:17
[error] Found:    trailhead.ids.ShelterId
[error] Required: trailhead.ids.TrailId
[error]   println(visit(ShelterId(1)))
[error]                 ^^^^^^^^^^^^

Checked constructors

Because every value of the type is made by the companion, the companion is also the place to enforce an invariant once. The example makes a positive-only distance and returns an Option for bad input; the unchecked variant is the only way in that skips the check, and it is named accordingly.

//> using scala 3.10.0
package trailhead
object o:
  opaque type Pos = Int
  object Pos:
    def from(n: Int): Option[Pos] = if n > 0 then Some(n) else None
    def unsafe(n: Int): Pos = n
  extension (p: Pos) def value: Int = p
import o.*
@main def run(): Unit =
  println(Pos.from(3).map(_.value))
  println(Pos.from(-1))
  val a: Pos = 3
[error] ./a6.scala:13:16
[error] Found:    (3 : Int)
[error] Required: trailhead.o.Pos
[error]   val a: Pos = 3
[error]                ^

Removing the last line, the program prints Some(3) and None. The literal 3 is rejected as a Pos, so the only checked way to get one is Pos.from. This is the smart constructor pattern of Case Classes without a wrapper object.

Bounds

An opaque alias may declare an upper bound, as in opaque type Meter <: Double = Double. The bound is visible outside the scope: a Meter is known to be a Double there, so it can be passed to math.sqrt and uses all of the operators of Double, yet it is still a distinct type that is created only by the companion. The bound is a promise about the representation, so use it only when you want to leak that much. The example also defines a Logarithm type with no bound that stores the logarithm of a value so that multiplication becomes addition, the standard example of the Scala reference.

//> using scala 3.10.0
package trailhead

object logs:
  opaque type Logarithm = Double
  object Logarithm:
    def apply(d: Double): Logarithm = math.log(d)
  extension (x: Logarithm)
    def toDouble: Double = math.exp(x)
    def *(y: Logarithm): Logarithm = x + y   // multiplying the real values adds the logs

  opaque type Meter <: Double = Double
  object Meter:
    def apply(d: Double): Meter = d

import logs.*

@main def run(): Unit =
  val m = Meter(1500.0)
  val d: Double = m
  println(math.sqrt(m))
  println(d * 2)
  println((Logarithm(2) * Logarithm(8)).toDouble)
38.72983346207417
3000.0
15.999999999999998

The last line shows the price of the representation: the logarithm round trip is not exact. The bound <: CharSequence on an opaque alias of String works the same way and exposes length and charAt (checked with 3.10.0). Some bounds are rejected: opaque type Id <: Comparable[Id] = String fails with Cyclic reference involving type Id, and a bound that conflicts with the alias (for example a trait that String does not extend) is rejected with object refs cannot be instantiated since it has a member TrailName with possibly conflicting bounds.

Transparency scope and top-level opaque types

The alias is transparent in the scope that defines it, which for an object or class is its body, including nested definitions. An opaque type can also be declared at the top level of a file, without an enclosing object. Then the transparent scope is the set of top-level definitions in that file, and nothing else: an object in the same file does not see through the alias, unlike a method that is itself top-level.

//> using scala 3.10.0

package trailhead

// top-level opaque type: transparent only to the other top-level definitions of this file
opaque type Minutes = Int
object Minutes:
  def apply(n: Int): Minutes = n
extension (m: Minutes) def plus(o: Minutes): Minutes = m + o
def rawMinutes(m: Minutes): Int = m

object Helper:
  def peek(m: Minutes): Int = rawMinutes(m)

@main def run(): Unit =
  println(Minutes(30).plus(Minutes(45)))
  println(Helper.peek(Minutes(7)))
75
7

Changing Helper.peek to return m directly is an error, Found: (m : trailhead.Minutes) Required: Int, as the reference describes. Top-level opaque types are convenient in small scripts and single-file modules; in a library prefer an enclosing object so that the transparent scope is explicit and the extension methods and instances sit beside the alias. The same program compiled and ran on 3.3.8 and on 3.9.0.

Type parameters and phantom types

An opaque alias may take type parameters, which makes "tagged" types: the same representation with a compile-time-only label. The example tags a number with the unit system it is in; two tags give two incompatible types.

//> using scala 3.10.0

package trailhead

object tags:
  opaque type Tagged[A, Tag] = A
  object Tagged:
    def apply[Tag]: [A] => A => Tagged[A, Tag] = [A] => (a: A) => a
    extension [A, Tag](t: Tagged[A, Tag]) def untag: A = t

trait Metric
trait Imperial

import tags.Tagged
import tags.Tagged.untag

def kilometres(d: Tagged[Double, Metric]): Double = d.untag

@main def run(): Unit =
  val m = Tagged[Metric](5.0)
  println(kilometres(m))
  val i = Tagged[Imperial](3.1)
  println(kilometres(i))
[error] ./b4.scala:23:22
[error] Found:    (i : trailhead.tags.Tagged[Double, trailhead.Imperial])
[error] Required: trailhead.tags.Tagged[Double, trailhead.Metric]
[error]   println(kilometres(i))
[error]                      ^

Without the last line the program prints 5.0. Metric and Imperial exist only to tell the two apart, they are never instantiated. The polymorphic function type in apply is covered in Dependent & Polymorphic Function Types.

Giving the type an API

An opaque type has no members, so every operation comes from one of three places. All are placed beside the alias.

  • Extension methods in the scope or the companion, as above. For a simple opaque type an extension defined in the companion is found without an import (o.Km(2.0).show compiled with the extension inside object Km). The generic Tagged example needed the explicit import tags.Tagged.untag, so import a companion extension when the lookup fails.

  • Given instances in the companion, such as given Ordering[TrailId] = Ordering.Int, so that List[TrailId].sorted works. A type class instance is the clean way to reuse the behaviour of the representation: Ordering, Numeric or a JSON codec for the underlying type can be re-exposed for the opaque type from inside the scope, where the two types are the same.

  • Bounds, to inherit all members of the supertype at once.

Zero-cost representation and its limits

The compiler erases an opaque type to its underlying type: there is no wrapper object, no method call overhead for the extension methods beyond the usual (they are methods of an object that take the underlying value) and no unbox step. The example shows what that means at run time for an Int-backed identifier.

//> using scala 3.10.0

package trailhead

object ids:
  opaque type TrailId = Int
  object TrailId:
    def apply(n: Int): TrailId = n
    given Ordering[TrailId] = Ordering.Int
  opaque type Elevation <: Int = Int
  object Elevation:
    def apply(m: Int): Elevation = m

import ids.*

@main def run(): Unit =
  val e = Elevation(1200)
  val asInt: Int = e
  println(asInt + 1)
  println(e > 1000)
  val ids = List(TrailId(3), TrailId(1), TrailId(2))
  println(ids.sorted)
  val arr = Array(TrailId(1), TrailId(2))
  println(arr.getClass.getSimpleName)
  val x: Any = TrailId(5)
  println(x.getClass.getName)
  println(x.isInstanceOf[Int])
  x match
    case i: Int => println("matched Int")
    case _ => println("no")
1201
true
List(1, 2, 3)
int[]
java.lang.Integer
true
matched Int

An Array[TrailId] is a real int[], with no per-element object. That is the biggest practical gain over a wrapper class. The same erasure has two consequences that the type system does not hide.

  • The distinction does not exist at run time. When an opaque value is widened to Any (or any generic position that is not specialised) it is boxed as the representation type, and isInstanceOf[Int] and a pattern case i: Int succeed on it, as the last three lines show. Do not use run-time type tests to tell two opaque types over the same representation apart. Since Scala 3 only allows such a test on a Matchable, widening to Any first is how this surfaces; see Type Hierarchy & Matchable.

  • Reflection, serialization and toString see the representation. A JSON library or a logger prints the raw number. Provide a type class instance in the companion if the opaque type needs a different encoding.

Value classes

A value class is a class that extends AnyVal, has exactly one val parameter in its single constructor and defines only methods. The compiler represents it by its parameter wherever it can and moves the methods to the companion object as extension methods that take the parameter, so Metres(3) has no wrapper when it is used as a Metres. Equality and the hash code are those of the underlying value. A value class may mix in a universal trait (a trait that extends Any), which is the only kind of trait it can extend.

//> using scala 3.10.0
package trailhead

class Metres(val value: Double) extends AnyVal:
  def +(o: Metres): Metres = Metres(value + o.value)
  def show: String = s"$value m"

trait Shown extends Any:
  def show: String
class Id(val n: Int) extends AnyVal with Shown:
  def show = s"#$n"

@main def run(): Unit =
  val m = Metres(10) + Metres(5)
  println(m.show)
  val xs: List[Metres] = List(Metres(1), Metres(2))
  println(xs.map(_.show))
  val o: Option[Metres] = Some(m)
  println(o)
  val s: Shown = Id(4)
  println(s.show)
  val arr = Array(Metres(1), Metres(2))
  println(arr.getClass.getSimpleName)
15.0 m
List(1.0 m, 2.0 m)
Some(trailhead.Metres@402e0000)
#4
Metres[]

Notice the two things that differ from an opaque type: the Option prints the default toString of the boxed class, and an Array[Metres] is an array of Metres objects, not of doubles. A listing of the compiled classes (javap -p, 3.10.0) shows how the methods moved: the companion object Metres$ has the instance methods $plus$extension(double, double), show$extension(double), equals$extension and hashCode$extension, the class Metres has static forwarders with the same names, and the class’s own instance methods exist only for the boxed form.

When a value class allocates

A value class is erased only while its static type is the value class. The compiler has to create a real object whenever the value is used as another type or stored where a reference is required. The example makes three functions and the bytecode of each (javap -c, 3.10.0) shows whether there is a new Metres.

//> using scala 3.10.0
package trailhead

class Metres(val value: Double) extends AnyVal:
  def +(o: Metres): Metres = Metres(value + o.value)

def direct(a: Double, b: Double): Double =
  (Metres(a) + Metres(b)).value        // no allocation

def boxedInList(a: Double): Any =
  List(Metres(a))                      // allocates: stored as a reference type

def asObject(m: Metres): Any = m       // allocates: the type Any needs an object

@main def run(): Unit =
  println(direct(1.0, 2.0))
  println(boxedInList(1.0).getClass.getSimpleName)

The relevant instructions of each method, as printed by javap -p -c:

direct:       invokevirtual Metres$.$plus$extension:(DD)D
boxedInList:  new trailhead/Metres ; invokespecial trailhead/Metres."<init>":(D)V
asObject:     new trailhead/Metres ; invokespecial trailhead/Metres."<init>":(D)V

direct calls only the extension method of the companion object Metres$ on two doubles; the other two create a Metres object.

In practice the value is boxed when it is

  • assigned to Any, AnyRef or a universal trait type,

  • used as the type argument of a generic class or method, such as List[Metres], Option[Metres] or Map[String, Metres] (the collections are generic and not specialised),

  • stored in an Array (an Array[Metres] is an array of objects), or

  • tested with isInstanceOf or matched by type.

A value class therefore saves allocations in local arithmetic and in method parameters but not in collections, which is where most code keeps them. An opaque type over Double has none of these costs inside an Array, although it is boxed to java.lang.Double in a generic position such as a List because that is what Double does.

Restrictions

A value class has strict rules, and the compiler reports each one. The example breaks two.

//> using scala 3.10.0
package trailhead
class Bad(val value: Int) extends AnyVal:
  val twice = value * 2
class Bad2(var value: Int) extends AnyVal
class Ok(val value: Int) extends AnyVal
@main def run(): Unit = println(Ok(1).value)
[error] ./a5.scala:4:3
[error] Value classes may not define non-parameter field
[error]   val twice = value * 2
[error]   ^^^^^^^^^^^^^^^^^^^^^
[error] ./a5.scala:5:16
[error] A value class parameter may not be a var
[error]                ^

Other rules checked with 3.10.0: a value class may not define an inner class (Value classes may not define an inner class), a secondary constructor (Value classes may not define a secondary constructor) or be a local class (Value classes may not be a local class), and it may mix in only universal traits (illegal trait inheritance (superclass AnyVal does not derive from the superclass of the trait)). The Scala documentation lists the same restrictions: exactly one val parameter, no other fields or initialisation, no nested classes, traits or objects, and no inheritance except from universal traits. A value class may define def`s and `type members, and a case class may extend AnyVal (case class M(value: Double) extends AnyVal compiled and copy worked).

Opaque types or value classes

Aspect Opaque type alias Value class

Wrapper allocated

Never; erased to the representation

Not in local use; yes in Any, generics, Option, collections and pattern matching

Array of the type

An array of the primitive (int[])

An array of objects

Run-time identity

None: isInstanceOf[Int] succeeds, no way to tell types apart

A real class when boxed, so isInstanceOf and pattern matching work

Members

None until you add extension methods, given instances or a bound

Methods defined in the class, usable like ordinary members

Representation

Anything: a primitive, a tuple, a function, another type

Exactly one val parameter of any type

Restrictions

Opaque scope; no run-time type test

No fields, one parameter, universal traits only, no nesting

Introduced

Scala 3.0

Scala 2.10

Prefer an opaque type when the goal is a distinct type over a primitive or any other single value, especially when it will live in collections or arrays, and when you want to control the API entirely. Use a value class only when the type has to exist at run time (for reflection, serialization, framework integration or a pattern match on its type), or when you need real member methods without writing extension methods. A case class is the right choice when the wrapper is meant to be a real data object with several fields; see Case Classes.

Scala 2, Java and Kotlin

  • Scala 2. Opaque types do not exist. The Scala 2 patterns are a value class (class Km(val v: Double) extends AnyVal), a tagged type (type Tagged[A, T] = A with Tag[T], the "shapeless style"), or a library such as newtype. Value classes are still valid in Scala 3 and the examples above compiled with 3.10.0.

  • Java. There is no equivalent in a released Java: a distinct type for a primitive is a wrapper class with an allocation per instance. Project Valhalla (JEP 401, value classes and objects) is preview work that aims to flatten such objects in the future; see JEP 401: Value Classes and Objects (Preview). It does not change Scala’s AnyVal or opaque types today.

  • Kotlin. A @JvmInline value class Km(val value: Double) is the direct counterpart of a Scala value class: one property, erased in local use, boxed in generic positions and collections, with the same rules. See Kotlin. A Kotlin typealias is a transparent name and does not create a new type, so it is not comparable to an opaque type.

Pitfalls

  • Forgetting that outside the scope the type has nothing: extensions, constructors and a type class instance must come from the defining scope. Missing members show up as "value x is not a member of …​ but could be made available as an extension method".

  • Expecting == between two opaque values to be safe across types. Km(2.0) == Min(2) compiled and printed true with 3.10.0, because equals is the one from Any and compares the underlying numbers. Use CanEqual (see Multiversal Equality) to make unrelated comparisons an error.

  • Relying on a run-time type test or on getClass to distinguish opaque types, or serialising them in a format that is keyed by class name.

  • Using a bound too eagerly: opaque type Km <: Double allows km + 1.0 and km * metres, giving back the problem the type was meant to solve.

  • A value class in a generic collection pays the allocation it was meant to avoid; measure with an Array or use an opaque type.

Where the books are outdated

  • Older books and articles (for example the Scala 2.10 era posts on value classes) describe "tagged types" and newtype libraries as the way to get a zero-cost type. In Scala 3 an opaque type alias does it in the language, and AnyVal is rarely the right first choice in Scala 3.

  • Books that present value classes as a general "free wrapper" do not stress the boxing list above; the actual saving is limited to local arithmetic.

  • Anything that predates Scala 3 does not cover opaque type bounds, top-level opaque types or the transparency rule of the file scope.

Scala 3.9 LTS versus 3.10

  • Opaque type aliases, their bounds and value classes are Scala 3.0 features with the same behaviour on 3.3, 3.9 and 3.10; the opaque-alias, top-level and value-class programs of this page printed the same output when compiled with 3.3.8 and 3.9.0.

  • Nothing on this page changed between the two lines in the examples run here; check the release notes of your version if you depend on an edge case of the transparency rules.