Zero‑Cost Domain Types in Scala 3: Opaque Types Explained
Learn how Scala 3 opaque types give you compile‑time safety without runtime overhead, how they differ from value classes and case classes, see a worked example, and understand key trade‑offs.
09 Jan 2026, 19:01 UTC

Problem: Mixing Up Domain IDs
In many business applications you’ll find yourself passing around identifiers – UserId, OrderId, InvoiceId – that are all represented by the same primitive, usually Long or String. A common source of bugs is accidentally swapping one for another, e.g. passing an OrderId to a function that expects a UserId. In Scala 2 you could wrap each ID in a case class or an AnyVal value class, but both approaches have drawbacks: case classes allocate per instance, and value classes can still box in generic contexts or pattern matching.
Scala 3 introduces opaque types – a zero‑cost way to give a primitive a distinct compile‑time type while erasing to the underlying representation at runtime. They let you catch the wrong‑type bug at compile time without paying the allocation cost.
Thesis: Opaque Types Are Zero‑Cost Domain Wrappers
Opaque types are a type alias that is only visible inside a specific scope. Outside that scope the alias is an abstract type that does not unify with the underlying primitive. Because the compiler erases the alias to the primitive at runtime, you get the same performance as using the primitive directly, but with the safety of a distinct type.
Syntax and Scope
Declare an opaque type inside an object or package where you want the type to be visible:
object Ids {
opaque type UserId = Long
opaque type OrderId = Long
}
Inside Ids the compiler knows that UserId is a Long, so you can assign a Long to it:
val raw: Long = 42L
val uid: Ids.UserId = raw // OK inside Ids
Outside Ids the alias is abstract, so the compiler disallows the assignment:
val uid: Ids.UserId = 42L // ❌ compile‑time error
To expose a construction method you typically add an apply in the companion object:
object Ids {
opaque type UserId = Long
def UserId(value: Long): UserId = value
opaque type OrderId = Long
def OrderId(value: Long): OrderId = value
}
Now callers can write Ids.UserId(42L) to get a typed value, and the compiler guarantees that a Long is not accidentally used as a OrderId.
Companion and Extension Methods
Because opaque types are not automatically treated like classes, you need to define any methods you want on them explicitly. The idiomatic way is to use extension methods in the companion object:
object Ids {
opaque type UserId = Long
def UserId(value: Long): UserId = value
extension (uid: UserId) {
def toLong: Long = uid
def isZero: Boolean = uid == 0L
}
}
Now you can call Ids.UserId(5L).isZero and the compiler will inline the method, keeping the zero‑cost guarantee.
Worked Example: Preventing Argument‑Swap Bugs
Suppose you have a service that creates an order for a user. The function signature should enforce that the first argument is a UserId and the second is an OrderId:
def createOrder(user: Ids.UserId, order: Ids.OrderId): Order = {
// implementation
}
Now consider a call site that accidentally swaps the arguments:
val u: Ids.UserId = Ids.UserId(10L)
val o: Ids.OrderId = Ids.OrderId(20L)
createOrder(o, u) // ❌ compile‑time error
The compiler rejects the call, preventing a subtle runtime bug. If you had used plain Long or a case class wrapper, the error would only surface at runtime or would require manual checks.
Trade‑offs and Limitations
Boilerplate and Visibility
Each opaque type requires an explicit constructor and, if you want methods, extension definitions. In a large domain model you may end up with many small companion objects, which can feel verbose compared to a single case class per ID.
Interop and Serialization
Because the type erases to the underlying primitive, Java callers see only the Long signature. If you expose an opaque type across a Java boundary, the API will look identical to a method that accepts a plain long, so the type safety is lost. The same applies to reflection‑based libraries or serialization frameworks that rely on runtime type information; you may need custom codecs or serializers to preserve the domain intent.
Pattern Matching and Generic Contexts
Opaque types behave like the underlying type in pattern matching. For example:
id match {
case 0L => "empty"
case _ => "non‑empty"
}
While this is usually acceptable, it means you cannot pattern match on the opaque type itself without first exposing a match type or using a wrapper. Additionally, generic code that uses ClassTag will see the erased type, which can lead to surprising runtime behavior if you rely on the distinct type for reflection.
Takeaway
Opaque types in Scala 3 give you:
- Compile‑time safety for domain primitives.
- No runtime allocation – the type erases to the underlying primitive.
- Explicit control over constructors and methods via companions.
- Two or more primitives share the same underlying type but should never be mixed.
- Performance matters and you want to avoid boxing in generic or pattern‑matching contexts.
- You’re comfortable adding the necessary boilerplate and handling interop manually.
Happy typing!
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.