Skip to content

Instantly share code, notes, and snippets.

@hanishi
Last active February 19, 2026 10:27
Show Gist options
  • Select an option

  • Save hanishi/967e1355da1eaf009a92b4ba393f4e9b to your computer and use it in GitHub Desktop.

Select an option

Save hanishi/967e1355da1eaf009a92b4ba393f4e9b to your computer and use it in GitHub Desktop.

Zero-Cost Type Class Derivation for Scala 3 Opaque Types

Introduction

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 = String

At 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.

Setup: A StringCodec Type Class

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): T

This 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 = s

The Opaque Type Problem

Now introduce an opaque type:

opaque type UserId = String

This 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 = s

But 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.

The Key Insight: Type Equality Evidence (=:=)

Scala provides =:= (defined in scala.Predef) — a type that serves as compile-time proof that two types are identical.

ev: T =:= String

If 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 → T

These 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.

The Solution: Blanket Inline Given + Exported Evidence

Step 1: The Derivation Rule

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.

Step 2: Exporting the Evidence

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) = summon

This 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.

Verification

With both pieces in place, the following resolves automatically from any call site:

summon[StringCodec[String]]   // identity codec (trivial)
summon[StringCodec[UserId]]   // derived via =:= evidence

The 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.

Scaling Up: Multiple Opaque Types

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) = summon

All four types — UserId, OrderId, Email, SKU — now have StringCodec instances with no per-type codec boilerplate.

Practical Example: Spray JSON Integration

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.

Generalizing Beyond String

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.

Limitations

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.

Encapsulation Tradeoff

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 = summon

The 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 entirely

This 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.

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment