Algebraic type sizes and domain modelling
Comprehending cardinality for fun and profit
In this post, we'll look at how to calculate the "size", or cardinality, of an algebraic type, and see how this knowledge can help us with design decisions.
Getting started
I'm going to define the "size" of a type by thinking of it as a set, and counting the number of possible elements.
For example, there are two possible booleans, so the size of the Boolean
type is two.
Is there a type with size one? Yes -- the unit
type only has one value: ()
.
Is there a type with size zero? That is, is there a type that has no values at all? Not in F#, but in Haskell there is. It is called Void
.
What about a type like this:
What is its size? There are three possible values, so the size is three.
What about a type like this:
Obviously, four.
I think you get the idea!
Calculating the size of compound types
For example, let's say that we have a Speed
as well as a Direction
, and we combine them into a record type called Velocity
:
What is the size of Velocity
?
Here's every possible value:
There are eight possible values, one for every possible combination of the two Speed
values and the four Direction
values.
We can generalize this into a rule:
RULE: The size of a product type is the product of the sizes of the component types.
That is, given a record type like this:
The size is calculated like this:
And similarly for a tuple:
The size is:
Sum types
Sum types can be analyzed the same way. Given a type Movement
defined like this:
We can write out and count all the possibilities:
So, five in all. Which just happens to be size(Direction) + 1
. Here's another fun one:
Again, we can write out and count all the possibilities:
There are three possible values in the YouSay
case, and three possible values in the ISay
case, making six in all.
Again, we can make a general rule.
RULE: The size of a sum or union type is the sum of the sizes of the component types.
That is, given a union type like this:
The size is calculated like this:
Working with generic types
What happens if we throw generic types into the mix?
For example, what is the size of a type like this:
Well, the first thing to say is that Optional<'a>
is not a type but a type constructor. Optional<string>
is a type. Optional<int>
is a type, but Optional<'a>
isn't.
Nevertheless, we can still calculate its size by noting that size(Optional<string>)
is just size(string) + 1
, size(Optional<int>)
is just size(int) + 1
, and so on.
So we can say:
Similarly, for a type with two generics like this:
we can say that its size can be calculated using the size of the generic components (using the "sum rule" above):
Recursive types
What about a recursive type? Let's look at the simplest one, a linked list.
A linked list is either empty, or it has a cell with a tuple: a head and a tail. The head is an 'a
and the tail is another list. Here's the definition:
To calculate the size, let's assign some names to the various components:
Now we can write:
Let's play with this formula a bit. We start with:
and let's substitute the last S with the formula to get:
If we clean this up, we get:
(where N^2
means "N squared")
Let's substitute the last S with the formula again:
and clean up again:
You can see where this is going! The formula for S
can be expanded out indefinitely to be:
How can we interpret this? Well, we can say that a list is a union of the following cases:
an empty list(size = 1)
a one element list (size = N)
a two element list (size = N x N)
a three element list (size = N x N x N)
and so on.
And this formula has captured that.
Calculating the size of functions
What about functions? Can they be sized?
Yes, all we need to do is write down every possible implementation and count them. Easy!
For example, say that we have a function SuitColor
that maps a card Suit
to a Color
, red or black.
One implementation would be to return red, no matter what suit was provided:
Another implementation would be to return red for all suits except Club
:
In fact we can write down all 16 possible implementations of this function:
Another way to think of it is that we can define a record type where each value represents a particular implementation: which color do we return for a Heart
input, which color do we return for a Spade
input, and so on.
The type definition for the implementations of SuitColor
would therefore look like this:
What is the size of this record type?
There are four size(Color)
here. In other words, there is one size(Color)
for every input, so we could write this as:
In general, then, given a function type:
The size of the function is size(output type)
to the power of size(input type)
:
Lets codify that into a rule too:
RULE: The size of a function type is
size(output type)
to the power ofsize(input type)
.
Converting between types
All right, that is all very interesting, but is it useful?
Yes, I think it is. I think that understanding sizes of types like this helps us design conversions from one type to another, which is something we do a lot of!
Let's say that we have a union type and a record type, both representing a yes/no answer:
How can we map between them?
They both have size=2, so we should be able to map each value in one type to the other, and vice versa:
This is what you might call a "lossless" conversion. If you round-trip the conversion, you can recover the original value. Mathematicians would call this an isomorphism (from the Greek "equal shape").
What about another example? Here's a type with three cases, yes, no, and maybe.
Can we losslessly convert this to this type?
Well, what is the size of an option
? One plus the size of the inner type, which in this case is a bool
. So size(YesNoOption)
is also three.
Here are the conversion functions:
So we can make a rule:
RULE: If two types have the same size, you can create a pair of lossless conversion functions
Let's try it out. Here's a Nibble
type and a TwoNibbles
type:
Can we convert TwoNibbles
to a byte
and back?
The size of Nibble
is 2 x 2 x 2 x 2
= 16 (using the product size rule), and the size of TwoNibbles
is size(Nibble) x size(Nibble), or 16 x 16
, which is 256.
So yes, we can convert from TwoNibbles
to a byte
and back.
Lossy conversions
What happens if the types are different sizes?
If the target type is "larger" than the source type, then you can always map without loss, but if the target type is "smaller" than the source type, you have a problem.
For example, the int
type is smaller than the string
type. You can convert an int
to a string
accurately, but you can't convert a string
to an int
easily.
If you do want to map a string to an int, then some of the non-integer strings will have to be mapped to a special, non-integer value in the target type:
In other words we know from the sizes that the target type can't just be an int
type, it must be an int + 1
type. In other words, an Option type!
Interestingly, the Int32.TryParse
function in the BCL returns two values, a success/failure bool
and the parsed result as an int
. In other words, a tuple bool * int
.
The size of that tuple is 2 x int
, many more values that are really needed. Option types ftw!
Now let's say we are converting from a string
to a Direction
. Some strings are valid, but most of them are not. But this time, instead of having one invalid case, let's also say that we want to distinguish between empty inputs, inputs that are too long, and other invalid inputs.
We can't model the target with an Option any more, so let's design a custom type that contains all seven cases:
But this design mixes up successful conversions and failed conversions. Why not separate them?
What is the size of StringToDirection_V2
?
There are 4 choices of Direction
in the Success
case, and three choices of ConversionFailure
in the Failure
case, so the total size is seven, just as in the first version.
In other words, both of these designs are equivalent and we can use either one.
Personally, I prefer version 2, but if we had version 1 in our legacy code, the good news is that we can losslessly convert from version 1 to version 2 and back again. Which in turn means that we can safely refactor to version 2 if we need to.
Designing the core domain
Knowing that different types can be losslessly converted allows you to tweak your domain designs as needed.
For example, this type:
can be losslessly converted to this one:
or this one:
Here's a real example:
You have a website where some users are registered and some are not.
For all users, you have a session id
For registered users only, you have extra information
We could model that requirement like this:
or alternatively, we can pull the common SessionId
up to a higher level like this:
Which is better? In one sense, they are both the "same", but obviously the best design depends on the usage pattern.
If you care more about the type of user than the session id, then version 1 is better.
If you are constantly looking at the session id without caring about the type of user, then version 2 is better.
The nice thing about knowing that they are isomorphic is that you can define both types if you like, use them in different contexts, and losslessly map between them as needed.
Interfacing with the outside world
We have all these nice domain types like Direction
or WebsiteUser
but at some point we need to interface with the outside world -- store them in a database, receive them as JSON, etc.
The problem is that the outside world does not have a nice type system! Everything tends to be primitives: strings, ints and bools.
Going from our domain to the outside world means going from types with a "small" set of values to types with a "large" set of values, which we can do straightforwardly. But coming in from the outside world into our domain means going from a "large" set of values to a "small" set of values, which requires validation and error cases.
For example, a domain type might look like this:
The values are constrained: max 50 chars for the name, a validated email, an age which is between 1 and 129.
On the other hand, the DTO type might look like this:
The values are unconstrained: any string for the name, a unvalidated email, an age that can be any of 2^32 different values, including negative ones.
This means that we cannot create a CustomerDTO
to DomainCustomer
mapping. We have to have at least one other value (DomainCustomer + 1
) to map the invalid inputs onto, and preferably more to document the various errors.
The final version of the mapping would then be from a CustomerDTO
to a SuccessFailure<DomainCustomer>
or similar.
So that leads to the final rule:
RULE: Trust no one. If you import data from an external source, be sure to handle invalid input.
If we take this rule seriously, it has some knock on effects, such as:
Never try to deserialize directly to a domain type (e.g. no ORMs), only to DTO types.
Always validate every record you read from a database or other "trusted" source.
Further reading
And after I wrote this, I was pointed to two similar posts:
As some of those posts mention, you can do strange things with these type formulas, such as differentiate them!
Summary
This might not be the most exciting topic in the world, but I've found this approach both interesting and useful, and I wanted to share it with you.
Let me know what you think. Thanks for reading!
Last updated
Was this helpful?