Scala 3 Opaque Types: Type-Safe IDs Without Runtime Wrappers
Opaque types make UserId and OrderId distinct to the Scala 3 compiler while erasing to Long at runtime. Here's the pattern, what erasure costs, and how to verify both.
20 Aug 2026, 23:18 UTC

The mix-up the type checker allows
A checkout service takes a user identifier and an order identifier. Both are Long. The signature is def charge(userId: Long, orderId: Long), and nothing stops a caller — or a later refactor — from passing them in the wrong order. The code compiles, any test that repeats the same wrong order passes, and the failure surfaces later as a lookup that quietly returns nothing.
Wrapping each identifier in a case class fixes the compile-time problem but adds an allocation per value and a layer of .value noise. Scala 3's opaque type is the middle path: a distinct type to the compiler, the same Long to the JVM.
Distinct outside, transparent inside
An opaque type is declared like a type alias, but its transparency is scoped:
object domain:
opaque type UserId = Long
Inside object domain — including nested definitions — UserId and Long are interchangeable. Everywhere else, UserId is its own type, and the only operations available are the ones its companion exposes. That asymmetry is the whole mechanism: the defining scope gets to build and unwrap values cheaply, and the rest of the program gets a type it cannot confuse with anything else.
Because the alias is erased, no wrapper object is emitted. That is the practical difference from Scala 2's AnyVal value classes, which can still box when used generically or stored in collections. Opaque types are Scala 3 only; on Scala 2 you need value classes or a newtype library.
A worked example
Here is the shape of the pattern for two identifiers and a validated email. Compile it yourself before trusting any of it — the snippet is illustrative, not measured output.
// domain.scala — Scala 3
object domain:
opaque type UserId = Long
object UserId:
def apply(value: Long): UserId = value
extension (id: UserId) def value: Long = id
given Ordering[UserId] = Ordering.Long
opaque type OrderId = Long
object OrderId:
def apply(value: Long): OrderId = value
extension (id: OrderId) def value: Long = id
opaque type Email = String
object Email:
def from(raw: String): Either[String, Email] =
val trimmed = raw.trim
if trimmed.contains("@") then Right(trimmed)
else Left(s"not an email: $raw")
extension (e: Email) def value: String = e
Three details carry the weight. UserId.apply is a plain identity function, and it is only legal because it sits inside the scope where the alias is transparent. Email.from is a smart constructor returning Either, so holding an Email is evidence that validation ran. And the given Ordering[UserId] is written by hand: outside the defining scope the compiler will not reuse Ordering[Long] for UserId, because they are different types.
At the call site the payoff is immediate:
// checkout.scala — Scala 3
import domain.*
def receiptFor(user: UserId, order: OrderId): String =
s"user ${user.value}, order ${order.value}"
val user = UserId(42L)
val order = OrderId(7L)
receiptFor(user, order) // compiles
receiptFor(order, user) // type mismatch: OrderId where UserId is expected
The second call is the point of the exercise. It fails at compile time, before any test runs. Extension methods declared in a companion are found through the implicit scope of the receiver type; if you place them elsewhere, import them explicitly.
What erasure costs you
- No overloading on the distinction.
def f(id: UserId)anddef f(id: Long)erase to the same signature and cannot coexist in one scope. - Reflection sees the underlying type. Serialization libraries, dependency-injection containers and mocking frameworks that inspect runtime classes see
LongorString. Some need codecs or instances supplied explicitly; behaviour varies by library, so check yours rather than assuming. - Opacity is one-way.
UserIdis not a subtype ofLong. Any API demanding a rawLongneeds an explicit unwrap. - The defining scope is unguarded. Inside
domain,UserIdandOrderIdare bothLong, so a mix-up there still compiles. Keep that scope small. - Tooling and language flags. Under strict equality you may need
CanEqualinstances, and syntax around givens and derives has shifted across Scala 3 minor releases. Check the release notes for the version you target.
Verifying it in five minutes
Run these in a scratch directory with Scala CLI or sbt on a Scala 3 version.
- Compile the snippets above. The deliberate
receiptFor(order, user)line must produce a type mismatch; comment it out to get a clean build. - Confirm transparency is scope-limited: add
val raw: UserId = 1Linsideobject domain(compiles) and outside it (does not). - Confirm erasure. Add
def raw(id: UserId): Long = id.valueto a small object, compile, then runjavap -pon the generated class file. The signature should readlong raw(long), not a wrapper class.
Step 3 is the one people skip and the one that settles the zero-cost question for their own build. Erasure means no wrapper is emitted; it does not by itself prove anything about allocation in generic contexts or after JIT optimisation, so measure if that matters to you.
Where this fits
Reach for opaque types when a primitive carries a domain meaning the compiler should enforce — identifiers, validated strings, units, currency amounts — and you want that enforcement without a wrapper on the hot path. Skip them when the type must be visible to reflection-based tooling, when it needs to be a subtype of the underlying type, or when the codebase is still on Scala 2. Start with one identifier type, compile it, run javap, and let the bytecode decide whether the pattern earns its place.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.