Primitive Type Differentiation in F#
Tags: fsharp, functional
Published: 2024-06-24 13:48:21

One of the reasons why most F# developers love the language is the strength of its type system. As everybody expects from a statically typed language, the compiler won't let us mix apples and oranges. If we try to pass a string where an integer is expected, the compiler will tell us that we are doing something wrong.

But how can we differentiate between an integer that represents the number of apples and an integer that represents the number of oranges? To highlight this problem, let's phrase it in more explicit terms. Imagine that we have a function that accepts two parameters: the age of a person and the number of children that the person has. Both parameters are integers, but the meaning of the numbers is different. If we accidentally switch the order of the parameters, the compiler won't be able to help us, because both parameters have the same type.

In this article, I will try to list the available options for solving this problem.

## Discriminated Unions

The discriminated union allows us to create a type that usually has multiple cases. But we can also use single-case discriminated unions for types that are semantically different from the built-in types.

```fsharp
type TB = B of int
type TC = C of int

let a = 1
let b = B 2
let c = C 3

let f (_: int) = None
let g (_: TB) = None
let h (_: TC) = None
```

In this example, the `TB` and `TC` types are different from the built-in `int` type. We can write `f a`, `g b`, and `h c`, but any other combination will result in a compilation error.

The single-case discriminated unions is a valid solution. The only drawback I can think of is that we have to wrap and unwrap the values frequently, which may lead to some boilerplate code.

## Units of Measure

The units of measure were introduced in F# to ensure correctness in numeric computations involving different kinds of quantities. This feature though can be leveraged to solve the problem we are facing.

```fsharp
[<Measure>]
type mb

[<Measure>]
type mc

let a = 1
let b = 2<mb>
let c = 3<mc>

let f (_: int) = None
let g (_: int<mb>) = None
let h (_: int<mc>) = None
```

Again, we can write `f a`, `g b`, and `h c`, but any other combination will make the compiler unhappy.

Instead of wrapping and unwrapping the values (like in the discriminated unions case), here, we convert the values to the desired type. For example, to convert `a` to `<mb>`, we can multiply it by `1<mb>` like this: `a * 1<mb>`. To convert `b` to `int`, we just need to call the `int` function: `int b`.

It seems to me that the units of measure is a more concise solution, but conceptually, they don't always fit for the problem we are trying to solve. When we deal with a unit of measure, we typically expect it to be a quantity we can perform specific numeric operations on. In the above example, we can multiply or divide `b` and `c`, but we can't add or subtract them. Although the compiler treats `<mb>` and `<mc>` as quantities, in reality, they are just labels.

## Extended Units of Measure

Despite the occasional mismatch between the units of measure and the problem domain, the feature is quite powerful for numeric types. For non-numeric types, there is a library called `FSharp.UMX`.

```fsharp
#r "nuget: FSharp.UMX, 1.1.0"

open FSharp.UMX

[<Measure>]
type sb

[<Measure>]
type sc

let a = "a"
let b: string<sb> = %"b"
let c: string<sc> = %"c"

let f (_: string) = None
let g (_: string<sb>) = None
let h (_: string<sc>) = None
```

Like all the other solutions, we can write `f a`, `g b`, and `h c`, but any other combination will be rejected by the compiler.

To convert `a` to `string<sb>`, we annotate the type and use the `%` operator like this: `let a1: string<sb> = %a`. To convert `b` to `string`, we can call the `string` function.

If we evaluate the expression `b = %"b"` we will get `true` and `[ b, 2 ] |> Map.ofList |> Map.find %"b"` will return `2`. This is good news. The extended units of measure seem to do a decent job when it comes to equality-based operations.

The downside is that they cannot be used in pattern matching.

## Conclusion

You expected me to present a perfect solution, didn't you? Well, that's not going to happen. I tried to list the available options I am aware of, and I am sure people will continue arguing about which solution is the best, no matter what.

Despite not seeing a clear solution to this particular problem, I remain convinced that F# is one of the best programming languages out there. If you like typed functional programming, and you don't want to dive into the great depths of category theory, chances are you already love F#.