Scala 3 Opaque Types: Zero-Cost Type Safety Without Wrapper Classes
Scala 3 opaque types give you distinct compile-time types with zero runtime overhead. Learn the syntax, a worked UserId/OrderId example, and the pitfalls: no pattern matching, manual APIs, and Scala 2 incompatibility.
21 Jul 2025, 08:58 UTC

The answer first
If you keep passing the wrong Int into the wrong method — a user ID where an order ID belongs — Scala 3's opaque types fix that at compile time with zero runtime overhead. Unlike a case class wrapper, an opaque type has the exact same bytecode representation as its underlying type. Unlike a plain type alias, it is a genuinely distinct type: the compiler refuses to mix UserId and OrderId even though both are Int underneath.
The trade-off: you must define the public API yourself (factory plus operations), pattern matching on opaque values is not supported, and the feature does not exist in Scala 2.
How the mechanism works
An opaque type is declared inside an object (or trait/class) with the opaque keyword:
object Ids:
opaque type UserId = Int
opaque type OrderId = Int
object UserId:
def apply(value: Int): UserId =
require(value > 0, "UserId must be positive")
value
extension (id: UserId)
def value: Int = id
object OrderId:
def apply(value: Int): OrderId =
require(value > 0, "OrderId must be positive")
value
extension (id: OrderId)
def value: Int = idThe key rule is scope. Inside the Ids object, UserId and Int are interchangeable — that is why apply can return a plain Int as a UserId. Outside that scope, the equality is hidden: callers see UserId as an abstract type with no known relationship to Int.
import Ids.*
val user: UserId = UserId(42)
val order: OrderId = OrderId(42)
// val bad: UserId = order // compile error: type mismatch
// val alsoBad: Int = user // compile error: UserId is not Int
val raw: Int = user.value // OK: explicit accessorAt runtime, both values are just integers on the JVM. There is no allocation, no boxing beyond what Int already implies, and no wrapper object. You get the safety of a wrapper class with the performance of a raw primitive.
Why not the alternatives?
| Approach | Type safety | Runtime cost | Notes |
|---|---|---|---|
type UserId = Int alias | None — pure synonym | Zero | Documents intent only; the compiler happily mixes aliases. |
case class UserId(v: Int) extends AnyVal | Full | Mostly zero, but boxes in generics, arrays, and some pattern-match paths | Works in Scala 2; boxing edge cases are hard to predict. |
| Opaque type | Full | Guaranteed zero | Scala 3 only; you write the API surface by hand. |
If you are on Scala 3 and performance matters — for example, IDs flowing through collections and generic code where AnyVal wrappers would silently box — opaque types are the right default.
Limits and common mistakes
1. Pattern matching does not work
You cannot match on an opaque type or destructure it, because outside its defining scope the compiler knows nothing about its structure. This fails to compile:
user match
case UserId(v) => v // error: cannot match on opaque typeExpose an extractor-style accessor instead (the value extension above) and match on that.
2. Forgetting to expose a conversion
Callers frequently need the raw value — to serialize to JSON, pass to a database driver, or interop with Java. If you do not provide an extension method like value, users are stuck. Plan the factory and the accessors together.
3. Generic code expecting the underlying type
An opaque type does not automatically satisfy constraints written against its underlying type. A method taking Int will not accept a UserId; a Numeric[Int] instance does not apply to UserId. Provide explicit given instances (for example, a JSON codec or an Ordering) inside the companion object, where the underlying representation is visible:
object UserId:
given Ordering[UserId] = Ordering.Int.on(identity)4. Cross-module visibility
The type equality UserId = Int is only visible in the scope where the opaque type is defined. If another module needs to construct or destructure values, it must go through the public factory and accessors — or you must deliberately re-export the capability. This is a feature (it enforces invariants) but surprises teams used to type aliases.
5. Reflection and Java interop see the underlying type
Because nothing is reified at runtime, a debugger, Java caller, or reflection-based library (some serializers, dependency-injection frameworks) sees Int, not UserId. If a library relies on runtime class tags to distinguish types, opaque types will not help you there.
6. Scala 2 incompatibility
The opaque keyword does not exist in Scala 2. Cross-built libraries typically guard opaque types behind version-specific source directories (src/main/scala-3) with an AnyVal fallback for Scala 2.
Verifying the behavior
Compile the example in any Scala 3 project (for example, sbt compile with scalaVersion := "3.3.0" or newer; no extra flags are needed in current Scala 3 releases). Then confirm the safety property by deliberately introducing a bad assignment — val bad: UserId = order — and checking that the compiler rejects it with a type mismatch. In the REPL (scala with a Scala 3 distribution), importing Ids.* and attempting the same assignment should produce the same error. If it compiles, you have accidentally written a plain type alias rather than an opaque type — check for the opaque keyword.
To confirm zero runtime cost, inspect the bytecode with javap -c on the compiled class: methods taking UserId will show int (or boxed Integer in generic positions) in their signatures, with no wrapper class present.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.