Scalable.FYI
ServicesBlogAbout UsCONTACTS

Why Use Scala / Opaque Types

Engineering Team · 4 min read

2026-08-10

Why Use Scala / Opaque Types

Introduction

Picture this scenario: you have a function signature for transferring funds, like this one

Scala
def transferFunds(fromAccount: String, toAccount: String, userId: String): Unit

The code compiles perfectly fine, no matter what order you pass those three strings in. After all, they're all just String(s), as far as the type system is concerned. Swap fromAccount and toAccount at a call site, and nothing catches it until money moves the wrong direction. Here's how to close that gap without paying a runtime cost to do it.

Python: NewType, a hint the runtime never sees

Python's typing.NewType creates a distinct type for the type checker only. As the docs say:

The Python runtime does not enforce function and variable type annotations. They can be used by third party tools such as type checkers, IDEs, linters, etc.

It's pure static-analysis sugar: even if if you happen to have a type checker installed and configured properly, it all disappears at runtime; a UserId, a SenderAccountId, or a ReceiverAccountId, are all just a str, indistinguishable from each other.

Python
UserId = NewType("UserId", str)
SenderAccountId = NewType("AccountId", str)
ReceiverAccountId = NewType("AccountId", str)

def transfer(frm: SenderAccountId, to: ReceiverAccountId, user: UserId) -> None: ...

# A type checker configured strictly WILL flag this:
transfer(receiver_id, sender_id, user_id)  # wrong types

# But it's only a hint: un-typed code, or code a type checker
# simply wasn't run against (common in real Python projects), gets
# zero protection. At runtime, UserId("abc") == AccountId("abc") is
# just str equality; the distinction never existed to begin with.

TypeScript: branded types, a manual and leaky workaround

TypeScript has no built-in nominal typing, so the community pattern is a "branded type": intersecting the real type with a phantom field that never actually exists at runtime. It works, provided you know the pattern and apply it consistently. And, as we have seen before, a cast with as walks straight past it.

TypeScript
type UserId = string & { readonly __brand: "UserId" };
type SenderAccountId = string & { readonly __brand: "SenderAccountId" };
type ReceiverAccountId = string & { readonly __brand: "ReceiverAccountId" };

function transfer(from: SenderAccountId, to: ReceiverAccountId, user: UserId): void {}

// Correctly rejected: passing a plain string where branded types are required.
transfer("acc_123", "acc_456", "user_789");

// This compiles fine, but the accounts are now swapped!
transfer("acc_456" as SenderAccountId, "acc_123" as ReceiverAccountId, "user_789" as UserId);

Java: real wrapper classes, real allocation cost

Java gets true type safety here with a wrapper class, or a record, as covered a few posts back. Unlike Python's and TypeScript's compile-time-only approximations, this one holds. It has a price: every UserId is a heap-allocated object rather than the raw String the JVM could otherwise pass around directly.

Java
record UserId(String value) {}
record SenderAccountId(String value) {}
record ReceiverAccountId(String value) {}

void transfer(SenderAccountId from, ReceiverAccountId to, UserId user) {}

Scala: opaque types, safety at compile time, nothing at runtime

An opaque type looks like a type alias. The difference is who can see through it: the underlying type is visible only to code sharing the scope where the opaque type is declared.

Everywhere else, the type checker treats it as a wholly separate type. That is why opaque types usually come with a companion object exposing an apply method, so outside code has a way to build one.

All of that distinction is erased during compilation, where the opaque type is replaced by the real one. No object creation, no allocation, no boxing. It exists only in the type checker's view of the world.

Scala
// Transfer.scala
opaque type UserId = String
object UserId:
  def apply(value: String): UserId = value

opaque type SenderAccountId = String
object SenderAccountId:
  def apply(value: String): SenderAccountId = value

opaque type ReceiverAccountId = String
object ReceiverAccountId:
  def apply(value: String): ReceiverAccountId = value

def transfer(from: SenderAccountId, to: ReceiverAccountId, user: UserId): Unit = {
  // because this method lives in the same scope of these three opaque types,
  // the type checker knows that they're all actually String(s), and therefore
  // we can use the "isEmpty" method from "String" on them
  if(from.isEmpty || to.isEmpty || user.isEmpty) {
    // throw some error
  }
  // continue processing the transfer
}

That's the whole definition: no wrapper class, no allocation-heavy record, nothing more than you'd write for a plain type alias. The opacity comes from where these lines live: an opaque type is transparent to its underlying String only within the scope that declares it, which is exactly why UserId's own companion object can write def apply(raw: String): UserId = raw without a cast, treating it as a plain String, or why we can call isEmpty directly from instances of UserId/SenderAccountId/ReceiverAccountId.

Every other interaction outside of that scope, though, just sees three, distinct types:

Scala
// BankOrder.scala, elsewhere in the codebase

val userId = UserId("u1")
val senderAccountId = SenderAccountId("a1")
val receiverAccountId = ReceiverAccountId("a2")

transfer(senderAccountId, receiverAccountId, userId) // compiles

// types don't match! we have a compiler error
// transfer(receiverAccountId, senderAccountId, userId)

You can't pass one where another is expected, and you can't call arbitrary String methods on a UserId without explicitly defining that they're allowed (typically via the extension methods from a few posts back).

Where each approach lands

Lay the four side by side and the matrix is clean. Python and TypeScript get the ergonomics, since there is no real wrapper to allocate, but not the safety, since both are erased or bypassable. Java gets real safety at a real runtime cost. Scala's opaque types are the only option here that gets both: compiler-enforced distinctness, with the runtime footprint of the underlying primitive.

Fixing the exact bug that started this post

Argument-order mix-ups, where correctly-typed but semantically wrong values go into a function with several same-typed parameters, are a documented failure mode in AI-generated code. An agent matching on "three strings go into this function" has no signal that the order matters unless the types carry it. With String parameters there is no signal. With UserId, SenderAccountId and ReceiverAccountId as opaque types, swapping two arguments is a compile error the agent has to fix before the build goes green. It gets caught in the same pass that catches a typo, and nothing about the performance stops you using it everywhere an ID flows through the system.