Using Scala 3 Opaque Types for Zero‑Runtime Encapsulation: An Architecture Note
Opaque types let a Scala 3 module expose a compile‑time distinct type while keeping a primitive representation at runtime. This note walks through requirements, minimal design, trust boundaries, operational checks, failure modes, and when to adjust the design.
03 Nov 2025, 10:25 UTC

Problem Statement
When building a public Scala 3 API we often want to expose a type that callers can use safely without exposing internal representation. For example, a Money value might be stored as a Long of cents, but we want callers to treat it as a distinct type to avoid accidental mixing with other numeric values. Traditional approaches—value classes, sealed traits, or wrapper classes—introduce runtime overhead or allow pattern matching that breaks encapsulation. Scala 3’s opaque types provide a compile‑time distinct type that is represented by a primitive at runtime, giving us zero‑overhead encapsulation.
Requirements
- Scala 3.2+ (opaque types are stable from 3.2).
- Module boundary: the API must be packaged separately from the implementation.
- No pattern matching on the opaque type by clients.
- Interop with Java or libraries that expect a concrete type requires explicit conversion.
- Optional serialization support via custom codecs.
Minimal Design
The smallest working pattern uses a trait for the API and a companion object that declares the opaque type and constructor. The implementation module knows the underlying representation.
// api/Money.scala (in the API module)
package com.example.api
trait MoneyApi {
// Opaque type declaration
opaque type Money = Long
object Money {
// Constructor that enforces invariants
def apply(cents: Long): Money = {
require(cents >= 0, "Money cannot be negative")
cents
}
// Controlled accessor
def value(m: Money): Long = m
}
}
In the implementation module we import the API and add domain logic. Because the opaque type is a type alias, the compiler treats Money as a distinct type at compile time but the JVM sees it as Long.
// impl/FinanceOps.scala
package com.example.impl
import com.example.api.MoneyApi
class FinanceOps extends MoneyApi {
def add(a: Money, b: Money): Money = Money(Money.value(a) + Money.value(b))
def toUSD(m: Money): Double = Money.value(m) / 100.0
}
Trust & Data Boundaries
- API Boundary: Exposes only the
Moneyopaque type and safe operations. - Implementation Boundary: Knows the underlying
Longrepresentation and can perform arithmetic. - Clients cannot pattern match or cast
MoneytoLongbecause the compiler forbids it; only thevalueaccessor is available. - Java callers see the underlying type unless we provide a Java‑friendly facade.
Operational Checks
- Compile with
-Xlint:opaque-typesto enforce no accidental unwrapping. - Run a unit test that attempts
m match { case Money(value) => … }and confirm a compile‑time error. - Inspect bytecode with
javap -classpath target/classes -p com.example.api.MoneyApito verify no new class is generated forMoney. - Add a simple Java class that calls
MoneyApi.Money.apply(100)and observe that Java code can only callvalue()if we expose it via a static method.
Failure Modes
- Mis‑use via unsafe casts:
m.asInstanceOf[Long]bypasses the compile‑time check but will succeed at runtime. Avoid exposing rawLongvalues. - Serialization defaults: Libraries like Circe will try to serialize the underlying
Longdirectly, which may be fine, but if you rely on custom JSON keys you must provide an encoder/decoder that usesvalue. - Mixed‑Scala‑2/Scala‑3 codebases: If the API is consumed by Scala 2 code, opaque types are not supported. In that case fall back to a value class or sealed trait, accepting the runtime cost.
- Reflection: Using
scala.reflect.runtime.universecan expose the underlying type; avoid reflection in the API.
Conditions that Would Change the Design
- If the API must be consumed by Scala 2 code, replace opaque types with a value class:
final case class Money(cents: Long)(runtime boxing occurs). - When Java interop is a primary consumer, provide a Java facade that wraps the opaque type: a static factory and getter.
- If you need to serialize the type to a custom format (e.g., Protobuf with a dedicated field), supply a custom encoder/decoder.
- When the underlying representation is non‑primitive (e.g., a complex struct), consider a sealed trait hierarchy with a single case object to keep zero runtime overhead.
Concrete Example: Currency‑Aware Money API
Below is a more realistic snippet that includes currency handling while keeping the opaque type zero‑overhead.
package com.example.api
trait MoneyApi {
opaque type Money = Long
object Money {
def apply(cents: Long, currency: String): Money = {
require(cents >= 0, "Money cannot be negative")
require(currency.matches("[A-Z]{3}"), "Invalid ISO currency code")
// Pack cents and currency into a 64‑bit value: lower 32 bits cents, upper 32 bits currency hash
val currencyHash = currency.hashCode & 0xffffffffL
(currencyHash << 32) | (cents & 0xffffffffL)
}
def value(m: Money): Long = m & 0xffffffffL
def currency(m: Money): String = {
val hash = (m >>> 32).toInt
// In a real system we would map hash back to currency string via a lookup table.
"USD" // placeholder
}
}
}
Clients can only use Money.value and Money.currency to access the data; the internal packing logic remains hidden.
Interoperability with Java
Java callers cannot refer to the opaque type directly. A small facade can bridge this gap:
package com.example.api;
public final class MoneyFacade {
public static long apply(long cents, String currency) {
return com.example.api.MoneyApi.Money.apply(cents, currency);
}
public static long value(long money) {
return com.example.api.MoneyApi.Money.value(money);
}
public static String currency(long money) {
return com.example.api.MoneyApi.Money.currency(money);
}
}
Java code can now call MoneyFacade.apply(100, "USD") and treat the returned long as an opaque value.
Serialization Considerations
Frameworks that rely on reflection (e.g., Jackson) will see the underlying Long and may serialize it directly. For JSON libraries that support custom codecs, provide an encoder that calls Money.value:
import io.circe.{Encoder, Decoder}
implicit val moneyEncoder: Encoder[com.example.api.MoneyApi.Money] = Encoder.encodeLong.contramap(com.example.api.MoneyApi.Money.value)
implicit val moneyDecoder: Decoder[com.example.api.MoneyApi.Money] = Decoder.decodeLong.emap { l =>
try Right(com.example.api.MoneyApi.Money.apply(l, "USD")) // default currency
catch { case _: IllegalArgumentException => Left("Invalid money value") }
}
Summary
Opaque types give you a clean, zero‑runtime encapsulation boundary: callers see a distinct type, the implementation knows the concrete representation, and no boxing occurs. They are ideal for API surfaces that require strict type safety and no runtime cost. However, they are Scala 3‑only, require careful handling for Java interop, and need custom serialization support if persistence is involved. When those constraints change, switch to value classes or sealed traits and accept the associated overhead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.