Scala 3 Opaque Types: Enforcing Domain‑Specific Type Safety Without Runtime Overhead
Learn how Scala 3’s opaque types let you distinguish domain values like UserId from OrderId at compile time, keep runtime representation lightweight, and still integrate with your existing code. A practical example, trade‑offs, and verification steps are included.
21 Aug 2025, 06:23 UTC

Why Do We Need Stronger Types?
When a codebase grows, the temptation to use raw primitives such as Int or String for domain concepts (user IDs, order IDs, account numbers) can lead to accidental mixing. A function that expects a UserId can be called with an OrderId without any compile‑time warning, and the bug only surfaces at runtime.
Traditional approaches in Scala—sealed traits, case classes, or wrapper objects—add runtime overhead or complicate interop with libraries that inspect concrete types. Scala 3 introduces *opaque types* to give you the best of both worlds: distinct compile‑time types with no runtime cost.
Opaque Types: The Scala 3 Solution
An opaque type declares a new type name that is treated as the underlying type by the compiler but is indistinguishable from that type at runtime. The syntax is:
opaque type UserId = String
Behind the scenes, UserId is represented as a String, but the compiler enforces that only values explicitly typed as UserId can be assigned to it. This gives you type safety without allocating a new class or adding a new field.
Defining an Opaque UserId
Below is a minimal module that defines UserId and OrderId, provides constructors, and exposes a helper for pattern matching. All of this lives in a single file for clarity.
package com.example.domain
object DomainTypes {
// Opaque type declarations
opaque type UserId = String
opaque type OrderId = String
// Companion object with controlled constructors
object UserId {
def apply(raw: String): UserId = raw
def unapply(id: UserId): Option[String] = Some(id)
}
object OrderId {
def apply(raw: String): OrderId = raw
def unapply(id: OrderId): Option[String] = Some(id)
}
}
Notice that the underlying representation is a String, but the compiler will reject any attempt to mix the two types.
Using the Types in Code
Here’s a quick illustration in the Scala REPL. The scalac command below compiles the definitions, and the REPL demonstrates the type safety.
scala> import com.example.domain.DomainTypes._
import com.example.domain.DomainTypes._
scala> val uid: UserId = UserId("u123")
uid: DomainTypes.UserId = u123
scala> val oid: OrderId = OrderId("o456")
oid: DomainTypes.OrderId = o456
scala> val wrong: UserId = oid
error: type mismatch; found : DomainTypes.OrderId
wrong: DomainTypes.UserId = oid
The compiler reports a type mismatch, preventing the accidental assignment.
Interacting with Existing Libraries
Opaque types are a compile‑time construct; at runtime they are the same as the underlying type. This means that:
- Libraries that rely on concrete class types (e.g., some ORMs) will see a
Stringand treat it normally. - Serialization libraries that introspect runtime types need custom encoders/decoders to preserve the opaque type distinction.
Example with Circe:
import io.circe.{Encoder, Decoder}
import io.circe.syntax._
implicit val userIdEncoder: Encoder[UserId] = Encoder.encodeString.contramap[UserId](id => id)
implicit val userIdDecoder: Decoder[UserId] = Decoder.decodeString.map(UserId.apply)
val json = UserId("u789").asJson
println(json.noSpaces) // prints "\"u789\""
Without the custom encoder/decoder, Circe would treat the value as a plain String, losing the type safety in the serialized form.
Trade‑offs and Verification
While opaque types offer compile‑time safety and zero runtime cost, they come with a few caveats:
- Reflection Limitations: Libraries that use Java reflection cannot distinguish opaque types from their underlying type. If you rely on reflection for mapping or serialization, you must provide custom logic.
- Pattern Matching Scope: Pattern matching on an opaque type is only possible within the scope where the type is defined. External modules must use the exposed
unapplymethod. - Interoperability: Some frameworks (e.g., certain ORMs) expect concrete types and may not play nicely with opaque types unless you provide adapters.
To verify that your opaque types work as intended, perform the following steps in a small test project:
- Compile with
scalac -version 3.5.0or later; ensure the compiler accepts the opaque type syntax. - Try assigning one opaque type to another in the REPL; the compiler should flag a type mismatch.
- Serialize an opaque value with a JSON library; confirm that you need a custom encoder/decoder.
- Run
scalafmtto ensure formatting does not alter the semantics.
These checks confirm that the type safety is enforced at compile time and that runtime behavior matches expectations.
Takeaway
Scala 3 opaque types give you a lightweight, compile‑time way to enforce domain‑specific type safety. They avoid the runtime overhead of wrapper classes while preserving the semantic distinction between values like UserId and OrderId. When integrating with libraries that rely on concrete types, be prepared to supply custom serializers or adapters. With careful design, opaque types can make your codebase more robust and self‑documenting.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.