Eliminating Bugs with Single Case Discriminated Unions
Tuesday, November 10, 2015
While reviewing the bugs that I've written in F# over the course of my most recent project I've found that the only recurring bug I had was passing arguments of the same type in the wrong order.
Here's a contrived and simple example of what I mean.
let assignUserToCustomer userId customerId = use command = new DbCommand() command.Execute(userId, customerId) Some() assignUserToCustomer customerId userId
In this case
customerId are both
ints so the fact that I've swapped them goes unnoticed by the compiler. The signature for
int -> int -> unit option so while it looks obvious in this case that I've swapped the order, the compiler doesn't know.
In languages without algebraic data types, resolving this problem typically leads to lots of primitive wrapper classes, each with loads of boilerplate to override equality and other such things.
In languages like F# and Haskell that have algebraic data types this is where a Single Case Discriminated Union (or Sum type) can eliminate this whole class of errors from happening.
What is a Single Case Discriminated Union?
Here's an example
type CustomerId = CustomerId of int //create a CustomerId let customer = CustomerId 123 //val customer : CustomerId = CustomerId 123 //get the int value of the CustomerId let (CustomerId id) = customer // val id : int = 123
It's just like any other Discriminated Union except that it only has one case. Note that it is common with this pattern for the case and the type to have the same name, the compiler is smart enough to know whether you're creating or destructuring.
Rewriting our error prone code
The first thing to do is create the Single Case Discriminated Unions that we need.
type CustomerId = CustomerId of int type UserId = UserId of int let customer = CustomerId 2 let user = UserId 123
Once we have created the types and we have values for the types (
user) we can alter our initial function to automatically destructure the types to ints so that we can maintain the same database calling code.
let assignUserToCustomer (UserId userId) (CustomerId customerId) = use command = new DbCommand() command.Execute(userId, customerId) Some() assignUserToCustomer user customer
If you look at the type of
customerId you'll see that they're both
ints which is what our data call expects. By defining the argument as
(UserId userId) we get to work with the underlying
int value immediately without the ceremony of having a full pattern match on each Single Case DU.
Perhaps even more important is that if you look at the signature for the function though it's now
UserId -> CustomerId -> unit option rather than
int -> int -> unit option. Our code will no longer compile if we attempt to reorder the arguments.