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:
addToCart( cartId = productId, productId = providerId, providerId = cartId )
Now it’s obvious! The arguments are completely out of order.
If we’re lucky enough this call is covered by a test case and will never make it to production.
If not it might slip through and fail at runtime when customers add items to their shopping cart. Or even worse it might succeed and corrupt the cart of another person leaving two unhappy customers.
Moreover it won’t probably be easy to figure out why such unexpected item ended up into a random customer cart who has never seen this product before.
Using named arguments help to see the issue early but yet the code still compile so it doesn’t really prevent it.
Note that if your using IntelliJ Idea it can show the argument names inline in the editor (even without named arguments ).
The solution
However we could have done a better job by making sure such code never compiles.
There is nothing tricky here we can just wrap each id
into its own case class.
final case class ProductId(val id: String) extends AnyVal final case class ProviderId(val id: String) extends AnyVal final case class CartId(val id: String) extends AnyVal
And then update the method signature accordingly.
def addToCart(cartId: CartId, productId: ProductId, providerId: ProviderId)
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.
Our function now becomes:
def addToCart(cartId: Id[Cart], productId: Id[Product], providerId: Id[Provider])
Moreover if we need to derive typeclass instances such as Json formats we just need to derive them once
import play.api.libs.functional.syntax._ import play.api.libs.json._ object Id { implicit def jsonFormat[A]: Format[Id[A]] = Format( Reads.StringReads.map(Id.apply[A]), Writes.StringWrites.contramap(_.id) ) }
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.