Scala 3's opaque type is one of the most compelling features for domain modeling. A single line gives you a brand-new type with zero runtime overhead.
opaque type UserId = StringAt compile time, UserId and String are completely distinct types — you get full type safety. At runtime, UserId is just a String — no wrapper object, no allocation, no cost.
But the moment you start using opaque types in a real project, you hit a wall: how do you derive type class instances?
UserId is String at runtime, but the compiler doesn't know that outside the definition scope. Even if StringCodec[String] exists, StringCodec[UserId] won't resolve automatically. The naïve fix — hand-writing an instance for every opaque type — buries your zero-cost abstraction under a pile of boilerplate.
This article shows how to solve the problem elegantly using Scala 3's type equality evidence (=:=) and inline given.
Let's define a simple type class that represents bidirectional conversion to and from String.
trait StringCodec[T]:
def encode(t: T): String
def decode(s: String): TThis T ⇄ String pattern shows up everywhere — JSON serialization, URL parameter encoding, database column mapping, and so on.
The instance for String itself is trivial:
given StringCodec[String] with
def encode(t: String): String = t
def decode(s: String): String = sNow introduce an opaque type:
opaque type UserId = StringThis creates a duality:
| Context | Behavior |
|---|---|
| Compile time | UserId ≠ String (distinct types) |
| Runtime | UserId is just String |
The compile-time side is the problem. Since UserId and String are different types, StringCodec[String] doesn't satisfy a requirement for StringCodec[UserId].
You can write the instance by hand inside the companion object, where the type alias is visible:
given StringCodec[UserId] with
def encode(t: UserId): String = t
def decode(s: String): UserId = sBut repeating this for OrderId, Email, SKU, and every other opaque type is a clear DRY violation. The implementations are identical — only the type name changes.
Scala provides =:= (defined in scala.Predef) — a type that serves as compile-time proof that two types are identical.
ev: T =:= StringIf an instance of T =:= String exists, the compiler has proven that T and String are the same type. You can't construct =:= instances yourself — only the compiler can provide them, and only when the equality genuinely holds. When you write summon[A =:= B], the compiler either proves the types are equal and hands you the evidence, or refuses to compile.
The evidence value also doubles as a conversion function:
ev(t: T): String // T → String
ev.flip(s: String): T // String → TThese conversions are identity casts at runtime — they exist only to satisfy the type system.
Here's the critical detail: inside an opaque type's defining scope, the compiler knows the underlying type and can provide =:= evidence implicitly. But outside that scope, the type is opaque — the compiler sees UserId and String as unrelated, and no =:= evidence is available.
This means we need two things: a generic derivation rule that works in terms of =:=, and a way to make the evidence available at the call site.
We define a universal derivation rule in StringCodec's companion object:
object StringCodec:
private def fromConversion[T](
to: T => String,
from: String => T
): StringCodec[T] =
new StringCodec[T]:
def encode(t: T) = to(t)
def decode(s: String) = from(s)
inline given derived[T](using inline ev: T =:= String): StringCodec[T] =
fromConversion(ev(_), ev.flip(_))fromConversion is a straightforward factory — given a pair of conversion functions, it builds a StringCodec[T]. Nothing interesting here.
The real work is in derived. This is a blanket given: it's defined for any type T, but constrained by the requirement that T =:= String can be proven. Only types that are genuinely equal to String — including opaque types defined as String — will satisfy this constraint.
The inline keyword is about performance, not correctness — the derivation works without it. What inline does is tell the compiler to resolve the evidence and expand the body at compile time. This means the =:= conversions (which are identity casts) are inlined away entirely, leaving no trace at runtime — no conversion overhead, no allocation, no evidence object.
The blanket given above requires T =:= String to be in implicit scope. Inside the opaque type's defining scope, the compiler knows UserId = String, so we can capture this knowledge as an exported given:
object UserId:
opaque type UserId = String
def apply(value: String): UserId = value
given (UserId =:= String) = summonThis line may look circular — we're defining a given using summon, which looks up a given. But it's not circular: inside this scope, the compiler already knows that UserId = String (it's a type alias here), so summon[UserId =:= String] succeeds using the compiler's built-in evidence. The given then re-exports that evidence so it's available outside the companion, where the type is opaque.
Without this line, code outside this scope has no way to prove UserId =:= String, and the blanket derivation won't fire.
This is still a significant win over writing codec instances by hand. You write one line of evidence export per opaque type, and you get derivation for every type class that uses the =:= pattern — StringCodec, JSON formats, database column mappers, and anything else you build this way.
With both pieces in place, the following resolves automatically from any call site:
summon[StringCodec[String]] // identity codec (trivial)
summon[StringCodec[UserId]] // derived via =:= evidenceThe resolution chain is: the compiler finds the given (UserId =:= String) exported from UserId's companion, which satisfies the constraint on StringCodec.derived, which produces a StringCodec[UserId]. The runtime cost is zero — the =:= conversions are inlined away entirely.
The pattern scales cleanly. Each new opaque type needs only the evidence export:
object OrderId:
opaque type OrderId = String
def apply(value: String): OrderId = value
given (OrderId =:= String) = summon
object Email:
opaque type Email = String
def apply(value: String): Email = value
given (Email =:= String) = summon
object SKU:
opaque type SKU = String
def apply(value: String): SKU = value
given (SKU =:= String) = summonAll four types — UserId, OrderId, Email, SKU — now have StringCodec instances with no per-type codec boilerplate.
Here's how StringCodec composes with a real serialization library. The following derives a Spray JSON JsonFormat from any available StringCodec:
given sprayJsonFormat[T](using codec: StringCodec[T]): JsonFormat[T] with
def write(t: T) = JsString(codec.encode(t))
def read(json: JsValue): T =
json match
case JsString(s) => codec.decode(s)
case other =>
deserializationError(s"Expected JSON string, got $other")Because StringCodec[UserId] is derived automatically, sprayJsonFormat[UserId] resolves with no additional wiring. The same applies to OrderId, Email, and every other opaque type that exports its evidence.
The same pattern works for any underlying type. Here's a sketch for Long-based opaque types:
trait LongCodec[T]:
def encode(t: T): Long
def decode(l: Long): T
object LongCodec:
inline given derived[T](using inline ev: T =:= Long): LongCodec[T] =
new LongCodec[T]:
def encode(t: T) = ev(t)
def decode(l: Long) = ev.flip(l)Replace Long with Int, UUID, BigDecimal, or any other type, and the technique works identically. You could also abstract over the base type itself with a higher-kinded OpaqueCodec[T, U], though the single-base-type version is usually clearer in practice.
There are a few boundaries to be aware of.
This approach applies to opaque types only. Wrapper classes like case class UserId(value: String) don't produce =:= evidence and aren't covered. For wrappers, you're better served by deriving through the constructor and extractor directly.
Each opaque type's companion must export its =:= evidence with an explicit given. This is a single line per type, but it is a line you have to remember to write. Forgetting it will produce a "no given instance found" error at the call site — the fix is straightforward once you know the pattern.
Exporting =:= evidence partially opens the opaque type's abstraction boundary. Consider an opaque type with validation:
object Email:
opaque type Email = String
def apply(value: String): Either[String, Email] =
if value.contains("@") then Right(value)
else Left(s"Invalid email: $value")
given Email =:= String = summonThe apply method enforces that every Email contains @. But the exported evidence provides a backdoor:
import Email.given
val ev = summon[Email =:= String]
val bad: Email = ev.flip("not-an-email") // bypasses validation entirelyThis Email value was never checked by apply — it violates the invariant.
When this is fine: opaque types that are pure newtypes with no invariants. The type exists for compile-time safety (don't mix UserId with OrderId), not to enforce constraints. This is the common case, and the pattern works well here.
When to be cautious: if your opaque type has validation logic and you need to guarantee that all values pass through it, exporting =:= creates a hole. In that case, write codec instances by hand inside the companion where you control construction, or use a smart constructor pattern without exporting the evidence.
By combining Scala 3's opaque type, type equality evidence (=:=), and inline given, you get type class derivation that is type-safe, zero-cost, and nearly boilerplate-free. The "compile-time distinct, runtime identical" nature of opaque types extends naturally into the type class world — you just need to bridge the scope boundary by exporting the evidence.
If your project makes heavy use of opaque types — and it should — introducing this pattern early will save you significant effort every time you add a new domain type.