Leveraging the type system to avoid mistakes

We all know we should write tests to make sure our system behaves as it is supposed to.

Surely tests are necessary to ensure correctness of our programs but they only depend on what the programmer is willing to test (or can think of testing).

What I mean is that there will always be gaps in the test coverage, like uncovered corner cases or improbable combinations of events, …

In Scala we have a powerful type system that we can use to help us avoid some mistakes.

Why would you bother writing a test to make sure a function handle some corner cases correctly when you can use the type system to make sure such cases won’t ever happen.

Don’t get me wrong I’m not saying to scrap all your test suites and try to encode all your constraints using the type system instead. Not at all I still firmly believe that tests are useful but in some cases you can leverage the type system to avoid mistakes.

The problem

Let me walk you through an example: Let’s imagine that you are developing an e-commerce platform and you’re in charge of the cart module.

The cart module is used by the customer to collect all the products it intends to buy before checkout. It’s a concept present on almost every e-commerce websites so I assume some familiarity with the idea.

Now let’s say that our cart module as a function to add an item into the cart. This function’s signature might look like the following:

def addToCart(cartId: String, productId: String, providerId: String)

This method looks quite alright at first sight. The intent is clear: it adds a product into a cart and we know what each argument represents.

We can even unit tests this method implementation. So far so good.

The mistake

Now let’s say that somewhere deeply nested in my application code I have this call:

addToCart(productId, providerId, cartId)

Do you see the problem? Maybe not … the program compiles just fine and everything seems on track.

Now let’s rewrite this call using named arguments to highlight the issue:

Now our call with out-of-order arguments no longer compiles and we can no longer use a ProductId where the compiler expects a CartId.

Note that our case classes are final because they’re not meant to be extended as it breaks equality.

Another thing worth noting is that they extend AnyVal which makes them value-classes.

Creating instances is a relative expensive operation. By turning them into value-classes we tell the compiler to treat these objects as String at runtime (no case class instantiation) while we still benefit from the type-safety at compile time.

It’s not always possible to keep value classes unwrapped at runtime there are some cases where they need to be boxed at runtime as explained here.

Improving the solution

We’ve improved the code quite a lot at this point while preserving the performances as mush as possible. However these 3 classes look pretty similar and it looks like we’re writing lots of boilerplate code here.

It would indeed be nice if we can have our cake and eat it too by having a single class Id without losing the benefit of having 3 distinct types. OF course this becomes possible if we add a type parameter to differentiate them.

case class Id[A](val id: String) extends AnyVal

Then we just need 3 different traits to use as type parameters

sealed trait Cart
sealed trait Product
sealed trait Provider

Notice that the type parameter A is removed at runtime. It’s sole purpose is to differentiate our Id types at compile time. Such type parameter is called a phantom type.

Conclusion

The scala type-system is pretty powerful and we can make it work to our advantage to make sure we don’t make silly mistakes (as illustrated here) when writing code. Of course it’s possible to achieve the same results by writing additional tests. However this approach limits the places of potential errors. There is now only one place where we need to be careful: when the Id[A] instances are created. Then we can rely on the type-system to enforce the type correctness of the parameters.