Data Modeling in Scala 3, but I only use types
That’s the whole idea.
We want to model data in Scala, but instead of using instances of classes at the term level, we want to use their type-constructors at the type level.
Let’s pick an example to help us visualise the whole process better, because just like a wise person once said, “A picture is worth a thousand words”.
We will represent candidate profiles in a recruitment process for software engineering companies. Let’s start with the term model code, and I’ll walk you through it.
We can represent candidates by providing their:
- Name — just a string
- Experience history — list of
- Other qualities — list of strings
And experience entry is represented by:
- Duration — number of months at the job
- Experience level —
enumvalue representing experience levels in IT
- Company name — a string
- Technologies — list of technologies used
So, if we were to create a very simplified profile using our model, it would look like this.
Cool, nothing new so far.
Making it spicier
That was some basic Scala. Now what we want to do is to be able to have all this information on the type level.
You might be asking: We already declared a model in the previous section. So can’t we just use that one?
As expected, the answer is: No. That’s because Scala distinguishes terms from types. The previous model worked on the term level, and we want to do it on the type level. So, we will have to tweak it a bit.
To make our intentions 100% clear, we want to be able to declare a type like the following (or at least similar).
Let’s start our work with the most basic class and work our way up the dependency graph.
First, let’s look at
ExpLevel. We declared it before as:
When we think about it, its type constructors carry the same amount of information as its data constructors, so we could leave it as it is.
There is a slight problem with the current declaration, though. When we want to access the type of
Junior and use it as, e.g. a type parameter for
List, we cannot just say
List[Junior]. That’s because there is no such type constructor as
Junior. So instead, we will have to type
List[Junior.type]. This can be pretty annoying, specifically when it’s a part of the interface exposed to the user.
Is there a way to fix it, then? Yes, and it’s actually quite simple. Just like by writing in Python, I can force myself into a crippling depression; you can force Scala to generate classes for all our cases by just adding parentheses after the constructors. Then, those won’t just be values but classes with empty constructors.
Nice. Let’s move onto the next one.
Now that we fixed the
ExpLevel data type let’s move on to Experience. In the term model, it looked like this:
We want all of those term parameters to become type parameters, so let’s try just adding them.
The strategy will be, for every term parameter, we will:
- create a type parameter with the same name
- add a type constraint for it using
We must use
<: here and not
:. That’s because when used on types, the first one is semantically equivalent to “is a subtype of”, and the latter means “has an implicit instance of”.
Let’s take a look at the result of our transformation, then.
At first glance, it looks OK, and it looks very similar to the term model. We have an entry for every parameter, and the constraints are the same as before. But does it work? Well, no. If I were to play the role of a build tool, I would say that we have one warning and one error.
Let’s start with the warning. Take a look at this class and think, what does the case keyword give us here. It gives us the apply function to our empty constructor, getters to our non-existent fields, the unapply function for a class we will never construct, and some other extremely useful methods.
Do you get the point? Here, the case keyword is just as useful as the const keyword in Java.
Cool. On to the error now. This one might not be as easy to spot. To make it easier, let’s look at how List is implemented. Skipping a lot of details, we have:
We have a supertype
List and two type constructors
:: (cons) and
Nil carries only a single piece of information since it just symbolises an empty list. No problem here. But, when we look at
::, it only has one type parameter. This would mean that it will only be able to carry the definition of one
String. That’s definitely not what we want.
Let’s create our own data structure, then. To make it easier, it should only contain
Voila. We just take a look at the definition of
List and move every term parameter to type-level, like before.
If we put all the parts together, we get.
Let’s take a look at our last class —
Right off the bat we can spot similar problems as with
Experience — Lists. Fortunately, we already have a structure for type-level lists of Strings from before. This means that we just need lists of
Experiences. So we can declare it in a similar way as with lists of strings, right? Let’s try.
Ok. This looks exactly like the
StrList with some minor name changes. Why is there a question mark instead of the constraint of the head? That’s because we cannot use
Experience is a type constructor that takes a non-empty parameter list. We would have to specify every parameter on the spot.
Is there some trick we can use here? Or is Scala’s type system not expressive enough?
Of course, there is a workaround. It is quite a common pattern. It’s every functional programmer’s biggest nightmare and every object-oriented programmer’s wet dream: Inheritance.
If we add a supertype to our
Experience class, we can use it in every place where we would usually use a type and treat
Experience as the implementation.
Is this solution pretty? No. But, as the tapeworm said: There was no other way.
Now that we have fixed this issue, there is nothing interesting anymore with transforming the
After all that work, we can finally write our correct example instance.
And it compiles, which means that it works!
C’mon, Do Something
You’re probably thinking: “Cool, we can model data now, but there is more to computer systems than just data.” There is always some domain logic that needs to be implemented. In our case, we should definitely add some sanity checks, like removing any experience in Rust and adding a “Good sense of humour” quality instead.
Can we do that? Yes, but since this blog post is already longer than the documentation for http4s, I will have to end it here.
I hope the content was at least mildly enjoyable and that you didn’t take anything I wrote seriously. Especially type-level programming.