Scala Value Classes: Zero-Cost Domain Wrappers Without the Boxing Traps
Scala value classes (AnyVal) give you type-safe domain wrappers like UserId that usually cost nothing at runtime. Here's the mechanism, a worked example, and where boxing still bites.
31 Jan 2026, 08:06 UTC

The useful answer first
If you want a UserId that the compiler will not let you swap with an OrderId, but you do not want to pay for a heap allocation on every call, declare it as a value class:
final case class UserId(value: Long) extends AnyValIn most hot paths the compiler rewrites this so the wrapper object never exists — your code runs on the raw Long. You get type safety at compile time and primitive cost at runtime. That is the entire pitch, and it holds for Scala 2.10 through 2.13 and Scala 3. The catch, covered below, is that "most hot paths" is not "everywhere": certain contexts silently box the wrapper back into a real object.
How the mechanism works
A value class must wrap exactly one val constructor parameter and extend AnyVal. During compilation, methods defined on the class are desugared into static methods on the companion object that take the underlying type as a parameter. So a call like userId.toHex becomes something equivalent to UserId$.toHex$extension(userId) — a static call passing a plain long. No instance is allocated.
This is why value classes beat two common alternatives:
- A type alias (
type UserId = Long) is fully transparent. A method expectingUserIdhappily accepts anyLong, so swapped-argument bugs compile fine. - A normal case class gives you the type safety but allocates a wrapper object per value, which matters in tight loops or high-throughput services.
A worked example
final case class Meters(value: Double) extends AnyVal {
def +(other: Meters): Meters = new Meters(value + other.value)
}
val total = Meters(3.0) + Meters(4.0)After the compiler's extension-method rewriting, the addition compiles down to arithmetic on two double values plus static method calls. The Meters wrapper typically never touches the heap. You can confirm this for your own build: compile the example with your project's Scala version and run javap -c on the produced class files (any JDK's javap works; no special permissions needed). Look for static $extension methods on the companion object and the absence of new Meters at the call site. Exact output differs between Scala 2.12, 2.13, and 3.x, so verify against your version rather than trusting any single blog post's bytecode dump.
Where boxing still happens
The wrapper class is still emitted to bytecode, and the compiler falls back to allocating it in several situations:
- Generic contexts.
List[Meters]stores boxed wrappers because of erasure — the list cannot hold unboxedMeters. Memory savings do not apply inside collections or maps. - Upcasting. Assigning a
MeterstoAnyorAnyValforces a real object. - Runtime type tests. Pattern matches and
isInstanceOfchecks need an actual instance to test against. - Some array and reflection scenarios, plus anything that calls
getClassor uses reference equality (eq).
The practical rule: value classes shine when values flow through concrete, monomorphic method signatures. They lose their advantage the moment values enter generic containers or runtime reflection.
Restrictions and common mistakes
The compiler enforces a strict shape: exactly one constructor parameter; the class cannot be extended and cannot extend a regular class; it must be top-level or a member of a statically accessible object; and it cannot contain inner classes or traits. Common mistakes seen in codebases:
- Assuming zero allocation everywhere. Profile before claiming it. JVM escape analysis can also mask allocation differences, so naive timing loops are unreliable — use JMH, which controls for JIT effects, if allocation behavior matters to your service.
- Trying to wrap multiple fields. A value class takes one parameter. A two-field
Point(x, y)needs a normal case class and its allocation cost. - Overriding
equals/hashCodeand expecting reference semantics. Value classes have value semantics by design; code depending on identity will misbehave or force boxing. - Calling
getClassoreqfor diagnostics — these expose or allocate the wrapper and can mislead you about what the optimized code actually does.
Scala 3: consider opaque types instead
Scala 3 offers opaque type Meters = Double, which gives the same compile-time distinction with no wrapper class emitted at all and no boxing in any context. If your project is Scala 3-only, opaque types are usually the better engineering choice. Value classes remain the right answer when you cross-compile between Scala 2 and 3, or when you need the wrapper to exist at runtime (for example, for Java interop or reflection-based libraries). Do not treat the two as interchangeable: they differ in boxing behavior, runtime representation, and binary compatibility.
Verifying the result
Because exact boxing sites and diagnostics vary across compiler versions, treat allocation claims as version-dependent. A sound workflow: compile a minimal example with your toolchain, inspect with javap -c, and benchmark with JMH before and after introducing the value class. Cross-check behavior against the official Scala documentation on value classes and, for Scala 3, the opaque types reference. If the wrapper shows up in allocation profiles inside collections, that is expected erasure behavior, not a bug in your code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.