Why architecture matters here
The architecture matters because primitive obsession is a silent, pervasive source of correctness bugs, and the usual fixes each carry a cost that discourages their use. You could wrap every id in a case class UserId(value: Long), and that gives you type distinctness — but it also allocates an object per id, adds a pointer indirection, and pressures the garbage collector, which in a hot path processing millions of ids is a real, measurable tax. So teams weigh the safety against the cost and, too often, skip the wrapper, leaving a Long that any other Long can be mistaken for. Opaque types remove the tradeoff: the distinctness is compile-time only, and the runtime representation is the naked primitive, so there is no allocation to weigh against the safety.
The second forcing function is encapsulation. A well-designed domain type should hide its representation: callers of an Email should not know or care that it is backed by a String, and crucially should not be able to construct one without going through validation. A plain type alias (type Email = String) provides zero encapsulation — it is just another name for String, fully interchangeable — so it neither prevents mixing nor guards construction. Opaque types provide true encapsulation: outside the companion, the underlying type is invisible, so the only way to make an Email is through the constructor you expose, which can validate. This is how you make 'a valid, parsed email' a type rather than a hope.
The third reason is that this safety composes with the rest of Scala's type system to make whole classes of state unrepresentable. Once UserId and OrderId are genuinely different types, a function signature fun grant(u: UserId, o: OrderId) cannot be called with the arguments swapped — the compiler rejects it — so an entire category of argument-order bugs simply cannot compile. Combined with smart constructors returning Either or Option, you push validation to the boundary and let the type carry the guarantee inward, so deep in the code you never re-check what the type already promises. That is the architectural payoff: correctness enforced by the compiler, at zero runtime cost.
A fourth benefit is that opaque types make refactoring safe in a way plain primitives never allow. When a concept is a real type, the compiler can follow it everywhere: change the underlying representation of Cents from Long to a bignum, and every site that used it through the type's operations continues to compile while genuinely-broken sites light up. With bare Longs scattered through the code, the same change is a manual, error-prone hunt with no compiler help, because the compiler cannot distinguish the Longs that were cents from the ones that were user ids. The type boundary you draw with an opaque type is therefore not just a runtime guard against mixing — it is a permanent handle the toolchain can grab onto during every future change, which is exactly the kind of leverage that pays off across a codebase's whole life rather than only at the moment it is written.
The architecture: every piece explained
Top row: definition and construction. The domain concept is the thing you want to make a real type — UserId, Meters, Email. The opaque type declaration, opaque type UserId = Long, states that inside its defining scope this new type is the underlying primitive, but that equivalence does not escape. The companion scope — the object or enclosing scope where the opaque type is declared — is the one and only place where UserId and Long are known to be the same, so it is where you write conversions between them. The smart constructor lives here: a function like def apply(s: String): Either[Error, Email] that validates the input and returns the opaque type only on success, so an Email that exists is always a valid one.
Middle row: usage and erasure. The type checker enforces distinctness everywhere outside the companion: a UserId cannot be passed where an OrderId is expected, cannot be added to a raw Long, cannot be accidentally unwrapped. Extension methods give the opaque type its behavior without exposing the underlying representation — you define extension (m: Meters) def +(o: Meters): Meters so arithmetic on distances is typed and distances cannot be added to temperatures. Erasure is the compiler step that, after all type-checking is done, replaces the opaque type with its underlying primitive in the generated bytecode. The result is no boxing: a UserId at runtime is literally a Long, with none of the allocation a case-class wrapper would incur.
Bottom rows: boundaries. The public API surface presents the opaque type as opaque to the outside world while the companion treats it transparently — this asymmetry is the whole design, and it means external code interacts only through the typed operations and constructors you provide. Interop and serialization handle the edges: when data crosses into JSON, a database, or another language, you need explicit conversions between the opaque type and its underlying representation, written in the companion where the equivalence is visible, so encoding and decoding are deliberate rather than accidental leaks. The ops strip names the practical signals: compile errors the types now catch (the bugs prevented), the allocation profile confirming zero wrapper cost, and API-leak checks ensuring the underlying type is never accidentally exposed.
End-to-end flow
Trace a payment domain built with opaque types. The team declares, in a domain object, opaque type UserId = Long, opaque type OrderId = Long, and opaque type Cents = Long, each with a smart constructor. Cents.fromLong rejects negatives, returning Either[Error, Cents]; UserId and OrderId wrap validated ids. From this point on, the three concepts are three distinct types even though all are Long underneath.
Now the compiler starts catching bugs that would previously have been runtime incidents. A developer writes chargeOrder(orderId, userId) for a function declared as chargeOrder(u: UserId, o: OrderId) — arguments swapped. Under plain Longs this compiles and charges the wrong entity in production; with opaque types the type checker rejects it immediately, because a UserId is not an OrderId. Similarly, an attempt to add a raw Long discount to a Cents total fails to compile until the discount is properly constructed as Cents, so a unit-mismatch bug never reaches a test, let alone a customer.
Inside the domain, extension methods make the types pleasant to use without ever unwrapping. Cents has a typed + and a formatting method; UserId has an equality that is naturally type-safe. None of this allocates: a profiler shows that a list of a million UserIds occupies exactly the memory of a million Longs, because after erasure that is precisely what it is. The safety cost nothing at runtime — the same code with case-class wrappers would have shown a million small objects and the attendant GC pressure.
Then the boundary. The service must return an order as JSON and read it back. Because JSON knows nothing of opaque types, the companion provides explicit codecs: an encoder that converts OrderId to its underlying Long for serialization and a decoder that runs the smart constructor on the way back in, re-validating rather than blindly trusting external data. This is the one place the equivalence is deliberately used, and keeping it confined to the companion means the rest of the codebase never sees the raw primitive. A careless alternative — exposing the underlying type in a public signature — would let external code treat an OrderId as a Long and reintroduce exactly the mixing the opaque type was meant to prevent, which is why the boundary conversions are written once, centrally, and audited.