layout: post
title: “Algebraic type sizes and domain modelling”
description: “Comprehending cardinality for fun and profit”

categories: []

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:

  1. type ThreeState =
  2. | Checked
  3. | Unchecked
  4. | Unknown

What is its size? There are three possible values, so the size is three.

What about a type like this:

  1. type Direction =
  2. | North
  3. | East
  4. | South
  5. | West

Obviously, four.

I think you get the idea!

Calculating the size of compound types

Let’s look at calculating the sizes of compound types now. If you remember from the understanding F# types series,
there are two kinds of algebraic types: “product” types such as tuples and records, and “sum” types, called discriminated unions in F#.

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:

  1. type Speed =
  2. | Slow
  3. | Fast
  4. type Velocity = {
  5. direction: Direction
  6. speed: Speed
  7. }

What is the size of Velocity?

Here’s every possible value:

  1. {direction=North; speed=Slow}; {direction=North; speed=Fast}
  2. {direction=East; speed=Slow}; {direction=East; speed=Fast}
  3. {direction=South; speed=Slow}; {direction=South; speed=Fast}
  4. {direction=West; speed=Slow}; {direction=West; speed=Fast}

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:

  1. type RecordType = {
  2. a : TypeA
  3. b : TypeB }

The size is calculated like this:

  1. size(RecordType) = size(TypeA) * size(TypeB)

And similarly for a tuple:

  1. type TupleType = TypeA * TypeB

The size is:

  1. size(TupleType) = size(TypeA) * size(TypeB)

Sum types

Sum types can be analyzed the same way. Given a type Movement defined like this:

  1. type Movement =
  2. | Moving of Direction
  3. | NotMoving

We can write out and count all the possibilities:

  1. Moving North
  2. Moving East
  3. Moving South
  4. Moving West
  5. NotMoving

So, five in all. Which just happens to be size(Direction) + 1. Here’s another fun one:

  1. type ThingsYouCanSay =
  2. | Yes
  3. | Stop
  4. | Goodbye
  5. type ThingsICanSay =
  6. | No
  7. | GoGoGo
  8. | Hello
  9. type HelloGoodbye =
  10. | YouSay of ThingsYouCanSay
  11. | ISay of ThingsICanSay

Again, we can write out and count all the possibilities:

  1. YouSay Yes
  2. ISay No
  3. YouSay Stop
  4. ISay GoGoGo
  5. YouSay Goodbye
  6. ISay Hello

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:

  1. type SumType =
  2. | CaseA of TypeA
  3. | CaseB of TypeB

The size is calculated like this:

  1. size(SumType) = size(TypeA) + size(TypeB)

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:

  1. type Optional<'a> =
  2. | Something of 'a
  3. | Nothing

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:

  1. size(Optional<'a>) = size('a) + 1

Similarly, for a type with two generics like this:

  1. type Either<'a,'b> =
  2. | Left of 'a
  3. | Right of 'b

we can say that its size can be calculated using the size of the generic components (using the “sum rule” above):

  1. size(Either<'a,'b>) = size('a) + size('b)

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:

  1. type LinkedList<'a> =
  2. | Empty
  3. | Node of head:'a * tail:LinkedList<'a>

To calculate the size, let’s assign some names to the various components:

  1. let S = size(LinkedList<'a>)
  2. let N = size('a)

Now we can write:

  1. S =
  2. 1 // Size of "Empty" case
  3. + // Union type
  4. N * S // Size of "Cell" case using tuple size calculation

Let’s play with this formula a bit. We start with:

  1. S = 1 + (N * S)

and let’s substitute the last S with the formula to get:

  1. S = 1 + (N * (1 + (N * S)))

If we clean this up, we get:

  1. S = 1 + N + (N^2 * S)

(where N^2 means “N squared”)

Let’s substitute the last S with the formula again:

  1. S = 1 + N + (N^2 * (1 + (N * S)))

and clean up again:

  1. S = 1 + N + N^2 + (N^3 * S)

You can see where this is going! The formula for S can be expanded out indefinitely to be:

  1. S = 1 + N + N^2 + N^3 + N^4 + N^5 + ...

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.

As an aside, you can calculate S directly using the formula S = 1/(1-N), which means that a list of Direction (size=4) has size “-1/3”.
Hmmm, that’s strange! It reminds me of this “-1/12” video.

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.

  1. type Suit = Heart | Spade | Diamond | Club
  2. type Color = Red | Black
  3. type SuitColor = Suit -> Color

One implementation would be to return red, no matter what suit was provided:

  1. (Heart -> Red); (Spade -> Red); (Diamond -> Red); (Club -> Red)

Another implementation would be to return red for all suits except Club:

  1. (Heart -> Red); (Spade -> Red); (Diamond -> Red); (Club -> Black)

In fact we can write down all 16 possible implementations of this function:

  1. (Heart -> Red); (Spade -> Red); (Diamond -> Red); (Club -> Red)
  2. (Heart -> Red); (Spade -> Red); (Diamond -> Red); (Club -> Black)
  3. (Heart -> Red); (Spade -> Red); (Diamond -> Black); (Club -> Red)
  4. (Heart -> Red); (Spade -> Red); (Diamond -> Black); (Club -> Black)
  5. (Heart -> Red); (Spade -> Black); (Diamond -> Red); (Club -> Red)
  6. (Heart -> Red); (Spade -> Black); (Diamond -> Red); (Club -> Black) // the right one!
  7. (Heart -> Red); (Spade -> Black); (Diamond -> Black); (Club -> Red)
  8. (Heart -> Red); (Spade -> Black); (Diamond -> Black); (Club -> Black)
  9. (Heart -> Black); (Spade -> Red); (Diamond -> Red); (Club -> Red)
  10. (Heart -> Black); (Spade -> Red); (Diamond -> Red); (Club -> Black)
  11. (Heart -> Black); (Spade -> Red); (Diamond -> Black); (Club -> Red)
  12. (Heart -> Black); (Spade -> Red); (Diamond -> Black); (Club -> Black)
  13. (Heart -> Black); (Spade -> Black); (Diamond -> Red); (Club -> Red)
  14. (Heart -> Black); (Spade -> Black); (Diamond -> Red); (Club -> Black)
  15. (Heart -> Black); (Spade -> Black); (Diamond -> Black); (Club -> Red)
  16. (Heart -> Black); (Spade -> Black); (Diamond -> Black); (Club -> Black)

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:

  1. type SuitColorImplementation = {
  2. Heart : Color
  3. Spade : Color
  4. Diamond : Color
  5. Club : Color }

What is the size of this record type?

  1. size(SuitColorImplementation) = size(Color) * size(Color) * size(Color) * size(Color)

There are four size(Color) here. In other words, there is one size(Color) for every input, so we could write this as:

  1. size(SuitColorImplementation) = size(Color) to the power of size(Suit)

In general, then, given a function type:

  1. type Function<'input,'output> = 'input -> 'output

The size of the function is size(output type) to the power of size(input type):

  1. size(Function) = size(output) ^ size(input)

Lets codify that into a rule too:

  • RULE: The size of a function type is size(output type) to the power of size(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:

  1. type YesNoUnion =
  2. | Yes
  3. | No
  4. type YesNoRecord = {
  5. isYes: bool }

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:

  1. let toUnion yesNoRecord =
  2. if yesNoRecord.isYes then
  3. Yes
  4. else
  5. No
  6. let toRecord yesNoUnion =
  7. match yesNoUnion with
  8. | Yes -> {isYes = true}
  9. | No -> {isYes = false}

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.

  1. type YesNoMaybe =
  2. | Yes
  3. | No
  4. | Maybe

Can we losslessly convert this to this type?

  1. type YesNoOption = { maybeIsYes: bool option }

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:

  1. let toYesNoMaybe yesNoOption =
  2. match yesNoOption.maybeIsYes with
  3. | None -> Maybe
  4. | Some b -> if b then Yes else No
  5. let toYesNoOption yesNoMaybe =
  6. match yesNoMaybe with
  7. | Yes -> {maybeIsYes = Some true}
  8. | No -> {maybeIsYes = Some false}
  9. | Maybe -> {maybeIsYes = None}

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:

  1. type Nibble = {
  2. bit1: bool
  3. bit2: bool
  4. bit3: bool
  5. bit4: bool }
  6. type TwoNibbles = {
  7. high: Nibble
  8. low: Nibble }

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:

Algebraic type sizes and domain modelling - 图1

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.

Algebraic type sizes and domain modelling - 图2

We can’t model the target with an Option any more, so let’s design a custom type that contains all seven cases:

  1. type StringToDirection_V1 =
  2. | North
  3. | East
  4. | South
  5. | West
  6. | Empty
  7. | NotValid
  8. | TooLong

But this design mixes up successful conversions and failed conversions. Why not separate them?

  1. type Direction =
  2. | North
  3. | East
  4. | South
  5. | West
  6. type ConversionFailure =
  7. | Empty
  8. | NotValid
  9. | TooLong
  10. type StringToDirection_V2 =
  11. | Success of Direction
  12. | Failure of ConversionFailure

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:

  1. type Something_V1 =
  2. | CaseA1 of TypeX * TypeY
  3. | CaseA2 of TypeX * TypeZ

can be losslessly converted to this one:

  1. type Inner =
  2. | CaseA1 of TypeY
  3. | CaseA2 of TypeZ
  4. type Something_V2 =
  5. TypeX * Inner

or this one:

  1. type Something_V3 = {
  2. x: TypeX
  3. inner: Inner }

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:

  1. module Customer_V1 =
  2. type UserInfo = {name:string} //etc
  3. type SessionId = SessionId of int
  4. type WebsiteUser =
  5. | RegisteredUser of SessionId * UserInfo
  6. | GuestUser of SessionId

or alternatively, we can pull the common SessionId up to a higher level like this:

  1. module Customer_V2 =
  2. type UserInfo = {name:string} //etc
  3. type SessionId = SessionId of int
  4. type WebsiteUserInfo =
  5. | RegisteredUser of UserInfo
  6. | GuestUser
  7. type WebsiteUser = {
  8. sessionId : SessionId
  9. info: WebsiteUserInfo }

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:

  1. type DomainCustomer = {
  2. Name: String50
  3. Email: EmailAddress
  4. Age: PositiveIntegerLessThan130 }

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:

  1. type CustomerDTO = {
  2. Name: string
  3. Email: string
  4. Age: int }

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.

This leads naturally to the Success/Failure model as described in my functional error handling talk,

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.

You might think that having everything wrapped in a Success/Failure type can get annoying, and this is true (!), but there are ways to make this easier.
See this post for example.

Further reading

The “algebra” of algebraic data types is well known. There is a good recent summary in
“The algebra (and calculus!) of algebraic data types”
and a series by Chris Taylor.

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!

If you like academic papers, you can read the original discussion of derivatives in “The Derivative of a Regular Type is its Type of One-Hole Contexts”(PDF) by Conor McBride from 2001,
and a follow up in “Differentiating Data Structures”(PDF) [Abbott, Altenkirch, Ghani, and McBride, 2005].

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!