Preventing Domain Logic Errors with Scala 3 Opaque Types
Learn how to use Scala 3 opaque types to eliminate primitive obsession and prevent domain logic errors with zero runtime overhead.
11 Mar 2026, 13:58 UTC

The Problem: Primitive Obsession
In large domain models, it is common to represent multiple identifiers—such as UserId, OrderId, and ProductId—as Long or String. This leads to "primitive obsession," where the compiler cannot distinguish between different IDs. A developer might accidentally pass a ProductId into a method expecting a UserId, and the code will compile and run, potentially corrupting data or returning incorrect records.
While Scala 2 value classes (AnyVal) attempted to solve this, they often introduce runtime overhead due to boxing in generic contexts or arrays. Scala 3 opaque types provide a zero-cost alternative: they create distinct types at compile time that are completely erased to their underlying primitive at runtime.
Prerequisites
- A Scala 3 project configured with a build tool (sbt, Mill, or scala-cli).
- Basic familiarity with extension methods and companion objects.
- An understanding that opaque types are scoped to the file or object where they are defined.
Implementing Zero-Cost Domain Wrappers
To ensure opacity is enforced, the opaque type must be defined in its own file. If defined in a shared object, any code inside that object can see the underlying type, bypassing your domain constraints.
1. Define the Opaque Type
Create a file (e.g., UserId.scala). Define the opaque type alias and a companion object to handle construction and access.
// File: UserId.scala
package com.example.domain
object UserId {
opaque type Type = Long
// Smart constructor to enforce validation
def apply(value: Long): Type =
if (value < 0) throw new IllegalArgumentException("ID cannot be negative")
else value
// Extension methods to provide access to the underlying value
extension (id: Type)
def value: Long = id
}
2. Usage in Domain Logic
Now, you can use UserId.Type throughout your application. Because the type is opaque outside of UserId.scala, the compiler treats it as a unique type, not as a Long.
import com.example.domain.UserId
case class User(id: UserId.Type, name: String)
case class Order(id: Long, userId: UserId.Type)
object UserService {
def findUser(id: UserId.Type): Unit = {
println(s"Fetching user with ID: ${id.value}")
}
}
// Usage
val myUserId = UserId(123L)
UserService.findUser(myUserId) // Compiles
// UserService.findUser(456L) // Compilation Error: Expected UserId.Type, found Long
Comparing Opaque Types to Alternatives
| Feature | Opaque Types (Scala 3) | Value Classes (AnyVal) | Case Classes |
|---|---|---|---|
| Runtime Overhead | Zero (Fully erased) | Low (Possible boxing) | High (Object allocation) |
| Type Safety | Strong (Compile-time) | Strong (Compile-time) | Strong (Compile-time) |
| Erasure | Erases to underlying type | Erases to underlying type | Remains a class |
Verification and Diagnostics
Compile-Time Type Separation
To verify the implementation, attempt to assign one opaque type to another. If you have both UserId.Type and OrderId.Type (both aliased to Long), the following should fail to compile:
val uId: UserId.Type = UserId(1L)
val oId: OrderId.Type = uId // Error: Found UserId.Type, Required OrderId.Type
Runtime Erasure Check
Since opaque types are erased, they do not exist as separate classes in the JVM. You can verify this by running javap -c on the compiled class file. You will see that the methods use long (the primitive) rather than a wrapper class.
Limitations and Risks
- Runtime Reflection: Because the type is erased, you cannot use
isInstanceOfor pattern matching on the opaque type outside its defining scope. You must use the accessor (.value) to perform checks on the underlying primitive. - Serialization: Many reflection-based JSON libraries (like some configurations of Jackson) will not recognize the opaque type. You will likely need to write custom encoders/decoders that map the opaque type to the primitive.
- Upper Bounds: If you define a type as
opaque type Positive <: Int = Int, the upper boundIntis published. This means the type remains opaque, but it can be treated as anIntin contexts where anIntis required, potentially leaking the primitive nature.
Recovery and Refactoring
If you find that your opacity is "leaking" (i.e., the compiler is allowing Long to be used where UserId.Type is expected), check the following:
- Scope Check: Ensure the usage is not occurring within the same file or object where the
opaque typeis declared. - Import Check: Ensure you are importing the type alias (
UserId.Type) and not accidentally using the underlying primitive in your method signatures. - Explicit Conversion: If a third-party library requires the raw
Long, use the extension method.valueto explicitly convert the type. Do not widen the opaque type definition to solve a local library requirement.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.