Data types in a programming language are the description or classification of the data that instructs the compiler how to treat the data. Of course, they are not only for the compiler or interpreter but also for us, the developers, as they help us understand the code big time.
This is a valid definition of the data which type is Map[String, String]:
val bookingPaymentMapping : Map[String, String] = Map(booking1.id -> payment1.id, booking2.id -> payment2.id)
This is the valid definition for our domain because both, booking and payment ids have the type String. Also for this trivial example, the type definition looks perfectly fine and pretty enough. However, we can imagine that in a more complex situation, in a bigger codebase we may lack some information when we see definitions like this:
val bookingPaymentMapping : Map[String, String]
As a result, it is not that rare that we see comments like this:
val bookingPaymentMapping : Map[String, String] //maps booking id -> payment id
We also quickly notice that it is not only about the readability of our code but also about the safety. This code perfectly compiles but it is not valid in our domain, it introduces a well-hidden bug:
val bookingPaymentMapping : Map[String, String] = Map(booking1.id -> payment1.id, payment2.id -> booking2.id)
What if we would like to add some extra information to the type definition? Something like “metadata” for the types? The information that not only helps the developers to comprehend the code but also introduces additional “type safety” by the compiler/interpreter.
The solution is there in the Scala ecosystem and it is called Tagged types.
There are two main implementations of Tagged types: Scalaz and Shapeless but also SoftwareMill’s Scala Common implementation should be mentioned. In this post, I will shortly show the usage of Shapeless Tagged types.
This is how the simple model that we convert to the new one using tagged types looks like:
import java.time.LocalDate case class Booking(id: String, date: LocalDate) case class Payment(id: String, bookingId: String, date: LocalDate) object TaggedTypes { val booking1 = Booking("bookingId1", LocalDate.now) val booking2 = Booking("bookingId2", LocalDate.now) val payment1 = Payment("paymentId1", booking1.id, LocalDate.now) val payment2 = Payment("paymentId2", booking2.id, LocalDate.now) val bookingPaymentMapping: Map[String, String] = Map(booking1.id -> payment1.id, booking2.id -> payment2.id) } object Payments { def payBooking(bookingId: String) = Payment("paymentId", bookingId, LocalDate.now) }
The final code should look close to this:
import java.time.LocalDate case class Booking(id: BookingId, date: LocalDate) case class Payment(id: PaymentId, bookingId: BookingId, date: LocalDate) object TaggedTypes { val booking1 = Booking("bookingId1", LocalDate.now) val booking2 = Booking("bookingId2", LocalDate.now) val payment1 = Payment("paymentId1", booking1.id, LocalDate.now) val payment2 = Payment("paymentId2", booking2.id, LocalDate.now) val bookingPaymentMapping: Map[BookingId, PaymentId] = Map(booking1.id -> payment1.id, booking2.id -> payment2.id) } object Payments { def payBooking(bookingId: BookingId) = Payment("paymentId", bookingId, LocalDate.now) }
We can already see that this code is easier to comprehend:
val bookingPaymentMapping: Map[BookingId, PaymentId]
but what we do not see yet is the fact that we also introduced the additional “type safety”.
How to get to that second implementation? Let’s do it step by step.
First, we need to create the tags:
trait BookingIdTag trait PaymentIdTag
These are simple Scala traits but actually other types can be used here as well. However, the trait is most convenient. The names have suffix Tag by convention.
We can use those tags to create tagged types:
import java.time.LocalDate import shapeless.tag.@@ trait BookingIdTag trait PaymentIdTag case class Booking(id: String @@ BookingIdTag, date: LocalDate) case class Payment(id: String @@ PaymentIdTag, bookingId: String @@ BookingIdTag, date: LocalDate) object TaggedTypes { val booking1 = Booking("bookingId1", LocalDate.now) val booking2 = Booking("bookingId2", LocalDate.now) val payment1 = Payment("paymentId1", booking1.id, LocalDate.now) val payment2 = Payment("paymentId2", booking2.id, LocalDate.now) val bookingPaymentMapping: Map[String @@ BookingIdTag, String @@ PaymentIdTag] = Map(booking1.id -> payment1.id, booking2.id -> payment2.id) } object Payments { def payBooking(bookingId: String @@ BookingIdTag) = Payment("paymentId", bookingId, LocalDate.now) }
This is basically how we tag the types. We say what type (String, Int, etc.) is tagged by which tag (String @@ StringTag, Int @@ IdTag, etc.).
But with this code we are still a bit far from our desired implementation. It is clear that these parts are boilerplate:
String @@ BookingIdTag
String @@ PaymentIdTag
We can easily replace them with type aliases (also presented in the previous post):
trait BookingIdTag trait PaymentIdTag package object tags { type BookingId = String @@ BookingIdTag type PaymentId = String @@ PaymentIdTag } case class Booking(id: BookingId, date: LocalDate) case class Payment(id: PaymentId, bookingId: BookingId, date: LocalDate) object TaggedTypes { val booking1 = Booking("bookingId1", LocalDate.now) val booking2 = Booking("bookingId2", LocalDate.now) val payment1 = Payment("paymentId1", booking1.id, LocalDate.now) val payment2 = Payment("paymentId2", booking2.id, LocalDate.now) val bookingPaymentMapping: Map[BookingId, PaymentId] = Map(booking1.id -> payment1.id, booking2.id -> payment2.id) } object Payments { def payBooking(bookingId: BookingId) = Payment("paymentId", bookingId, LocalDate.now) }
With this implementation, we are very close to what we would expect but this code still does not compile:
[error] /Users/Damian/local_repos/scala-tagged-types/src/main/scala/com/dblazejewski/taggedtypes/TaggedTypes.scala:23: type mismatch; [error] found : String("bookingId1") [error] required: com.dblazejewski.taggedtypes.tags.BookingId [error] (which expands to) String with shapeless.tag.Tagged[com.dblazejewski.taggedtypes.BookingIdTag] [error] val booking1 = Booking("bookingId1", LocalDate.now)
This says that we are using String type in the parameter which is expected to be a tagged type:
val booking1 = Booking("bookingId1", LocalDate.now)
The constructor (apply() method) of Booking case class expects tagged type but we supplied it with simple String. To fix this we need to make sure that we create the instance of the tagged type. This is how it can be done:
import com.dblazejewski.taggedtypes.tags.{BookingId, PaymentId} import shapeless.tag.@@ import shapeless.tag trait BookingIdTag trait PaymentIdTag package object tags { type BookingId = String @@ BookingIdTag type PaymentId = String @@ PaymentIdTag } case class Booking(id: BookingId, date: LocalDate) case class Payment(id: PaymentId, bookingId: BookingId, date: LocalDate) object TaggedTypes { val bookingId1: BookingId = tag[BookingIdTag][String]("bookingId1") val bookingId2: BookingId = tag[BookingIdTag][String]("bookingId2") val paymentId: PaymentId = tag[PaymentIdTag][String]("paymentId") val paymentId1: PaymentId = tag[PaymentIdTag][String]("paymentId1") val paymentId2: PaymentId = tag[PaymentIdTag][String]("paymentId2") val booking1 = Booking(bookingId1, LocalDate.now) val booking2 = Booking(bookingId1, LocalDate.now) val payment1 = Payment(paymentId1, booking1.id, LocalDate.now) val payment2 = Payment(paymentId2, booking2.id, LocalDate.now) val bookingPaymentMapping: Map[BookingId, PaymentId] = Map(booking1.id -> payment1.id, booking2.id -> payment2.id) } object Payments { import TaggedTypes._ def payBooking(bookingId: BookingId) = Payment(paymentId, bookingId, LocalDate.now) }
This is how we defined instances of tagged types:
val bookingId1: BookingId = tag[BookingIdTag][String]("bookingId1") val bookingId2: BookingId = tag[BookingIdTag][String]("bookingId2")
Now the code compiles.
The code is also on the github.
Let summarize what we achieved here:
- the intention of the code is clearly visible:
val bookingPaymentMapping: Map[BookingId, PaymentId]
We know immediately that the bookingPaymentMapping maps booking ids to payment ids.
- we get errors in the compilation time when we accidentally switch the ids:
val bookingPaymentMapping: Map[BookingId, PaymentId] = Map(booking1.id -> payment1.id,<br> payment2.id -> booking2.id)
The examples presented in this post are trivial but even though we see the clear benefits of using tagged types. Imagine the complex project and I think we are fully convinced that this is really usefuly technique for every Scala developer toolset.
There also are https://github.com/Rudogma/scala-supertagged and https://github.com/Treev-io/tagged-types
Nice, thanks.
Why not just use value classes here?
Thanks for your comment.
At a glance, value classes and tagged types look interchangeable.
However, they are two different entities:
with value classes, you just wrap values (for type safety for example) while with tagged types you simply create a new type from an existing one.
As Scalaz docs (https://oss.sonatype.org/service/local/repositories/releases/archive/org/scalaz/scalaz_2.11/7.2.2/scalaz_2.11-7.2.2-javadoc.jar/!/index.html#scalaz.example.TagUsage$)
says the value classes require runtime boxing/unboxing which can be a real runtime overhead (collections). Tagged types on the other hand do not require boxing/unboxing.
Another point is that when using Tagged types you still can use all methods from the original type that you extend (like
toUpperCase
from String, etc.). It is not that obvious with value classes (but still can be done I think).The obvious advantage of value classes for me is that you do not require any additional dependencies (on Shapeless or Scalaz) but you stick to plain Scala.
So, as usual, it is a matter of knowing pros/cons of each tool and use the right one for your particular use case.
Thanks for detailed reply!
It seems that tagged types is more heavyweight alternative to ‘newtype’-s in Haskell. Am I right?
I am not that knowledgeable in Haskell but you are right, indeed. newtype or data (single-constructor only) in Haskell seem to serve the same purpose.
Thanks Nikolay.
It’s not very heavyweight. The tagging is compile time only so at runtime they revert to the untagged raw types. So an Int is still an Int. For example, there’s no runtime space overhead for arrays of tagged ints – it’s still the unboxed primitive
Thanks for explanation, guys! Btw, I found library that generalizes type tagging approach to defining newtypes in Scala: https://github.com/estatico/scala-newtype
Hi, Nice.
I’m doing something almost identical but I use also something like
package object tags {
type BookingId = String @@ BookingIdTag
object BookingId {
def apply(s: String): BookingId = tag[BookingIdTag][String](s)
}
}
which allows the rather more succinct
val bookingId1: BookingId = BookingId("bookingId1")