DEV Community

AranaDeDoros
AranaDeDoros

Posted on

Stop Passing Strings Around Your Scala Domain

At some point in every codebase, someone writes a function like this:

def transferMoney(
  fromAccount: String,
  toAccount: String,
  amount: BigDecimal
): F[Unit]
Enter fullscreen mode Exit fullscreen mode

Looks harmless.

Until someone does this:

transferMoney(toAccount, fromAccount, amount)
Enter fullscreen mode Exit fullscreen mode

The compiler is perfectly happy.

String is String is String.

You don't discover the mistake until the wrong account gets charged.

This is one of the things I enjoy about Scala: it gives you tools to make these mistakes impossible to express in the first place.

The obvious solution: a wrapper

One way to solve the problem is to introduce a domain type:

final case class AccountId(value: String)
Enter fullscreen mode Exit fullscreen mode

Now the compiler can distinguish an AccountId from a String.

That's already a huge improvement.

But sometimes you don't actually need a new runtime object. You just need the compiler to understand that these two Strings represent different things.

That's where Scala 3's opaque types become interesting.

Opaque types

An opaque type lets you create a distinct type at compile time while retaining the underlying representation at runtime.

opaque type AccountId = String

object AccountId:
  def apply(raw: String): AccountId = raw

  extension (id: AccountId)
    def value: String = id
Enter fullscreen mode Exit fullscreen mode

Now we can define:

opaque type MerchantId = String
opaque type Cents = Long
Enter fullscreen mode Exit fullscreen mode

and our domain can become much more explicit:

def transferFunds(
  from: AccountId,
  to: AccountId,
  amount: Cents
): IO[Unit] =
  IO.println(s"Transferring $amount from $from to $to")
Enter fullscreen mode Exit fullscreen mode

The important part isn't that the code looks nicer.

It's what the compiler prevents.

Given:

val accountId: AccountId = AccountId("account-123")
val merchantId: MerchantId = MerchantId("merchant-456")
val cents: Cents = Cents(5000)
Enter fullscreen mode Exit fullscreen mode

this is fine:

transferFunds(accountId, accountId, cents)
Enter fullscreen mode Exit fullscreen mode

while this isn't:

transferFunds(merchantId, accountId, cents)
Enter fullscreen mode Exit fullscreen mode

Even though both AccountId and MerchantId are represented by String underneath.

We've moved the bug from:

"Someone eventually noticed the wrong value was passed."

to:

"The compiler won't let me write this."

That's a pretty good trade.

But what about runtime overhead?

This is one of the reasons I like opaque types for small domain primitives.

A case class introduces a wrapper:

final case class AccountId(value: String)
Enter fullscreen mode Exit fullscreen mode

An opaque type doesn't introduce a new runtime wrapper around the underlying value.

Conceptually:

AccountId → String
MerchantId → String
Cents → Long

at runtime, while remaining distinct types to the compiler.

That makes opaque types particularly attractive for domain concepts that are fundamentally represented by primitives:

Account IDs
User IDs
Merchant IDs
Emails
Currency amounts
Version strings
External reference IDs

You get stronger domain boundaries without changing the underlying representation.

Opaque types aren't just for IDs

Consider email addresses.

You could have:

opaque type Email = String

object Email:
  def parse(raw: String): Either[String, Email] =
    if raw.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
    then Right(raw)
    else Left(s"'$raw' is not a valid email")

  extension (email: Email)
    def value: String = email
Enter fullscreen mode Exit fullscreen mode

Now your application can distinguish:

String from Email

and, more importantly, you can establish an invariant:

If I have an Email, it has already passed my validation.

Instead of scattering validation throughout the application:

def sendEmail(address: String): ...
Enter fullscreen mode Exit fullscreen mode

you can make the boundary explicit:

def sendEmail(address: Email): ...
Enter fullscreen mode Exit fullscreen mode

The function doesn't need to wonder whether the string is valid.

Someone had to construct an Email first.

This is one of the ideas that makes type-driven domain design so powerful: push invalid states toward the boundaries of your system.

There's a catch

Opaque types aren't a magic replacement for every case class.

If your concept has several related pieces of data:

case class Money(
  amount: BigDecimal,
  currency: Currency
)
Enter fullscreen mode Exit fullscreen mode

a case class is probably the better abstraction.

Opaque types work particularly well when you have:

"this String actually means X"

or:

"this Long actually means Y"

They are also worth understanding when you're working with libraries such as Cats, Cats Effect, http4s, and Circe.

One thing that can surprise you is that type class instances don't automatically appear just because the underlying type has one.

For example, if you want to serialize:

AccountId

you may need to explicitly provide the appropriate encoder/decoder rather than expecting the String instance to magically apply.

That's a small amount of boilerplate, but it's worth knowing before introducing opaque types everywhere.

A useful rule of thumb

The next time you see this:

def process(
  userId: String,
  accountId: String,
  externalId: String
): ...
Enter fullscreen mode Exit fullscreen mode

stop for a second.

Ask yourself:

Are these really three Strings?

They probably aren't.

They're three different concepts that happen to share the same representation.

That's exactly the kind of situation where opaque types shine.

opaque type UserId = String
opaque type AccountId = String
opaque type ExternalId = String
Enter fullscreen mode Exit fullscreen mode

Now the compiler knows something that your original function didn't express.

And that's ultimately why I like this feature.

The goal isn't to use more types for the sake of using more types.

The goal is to make the domain model communicate things that would otherwise exist only in developers' heads.

Further reading

This is adapted from the first chapter of my book, Scala 3 Domain Design & Typelevel Stack Cookbook.

I'm writing the book incrementally on Leanpub and it's currently about 40% complete. The focus is practical Scala 3 and Typelevel patterns for developers who want to build real applications rather than work through another general Scala introduction.

You can read the currently available chapters here:

https://leanpub.com/scala3-domain-typestack-dev

Top comments (0)