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.
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.
[<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
.
#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#.
Top comments (0)