Introduction to Functional Programming in F# – Part 9

Introduction

In this post we are going to see how we can improve the readability of our code by increasing our usage of domain concepts and reducing our use of primitives.

Setting Up

We are going to use the code from Part 1.

Solving the Problem

This is where we left the code from the first post in this series:

type RegisteredCustomer = {
    Id : string
}

type UnregisteredCustomer = {
    Id : string
}

type Customer =
    | EligibleRegisteredCustomer of RegisteredCustomer
    | RegisteredCustomer of RegisteredCustomer
    | Guest of UnregisteredCustomer

let calculateTotal customer spend = // Customer -> decimal -> decimal
    let discount = 
        match customer with
        | EligibleRegisteredCustomer _ when spend >= 100.0M -> spend * 0.1M
        | _ -> 0.0M
    spend - discount

let john = EligibleRegisteredCustomer { Id = "John" }
let mary = EligibleRegisteredCustomer { Id = "Mary" }
let richard = RegisteredCustomer { Id = "Richard" }
let sarah = Guest { Id = "Sarah" }

let assertJohn = calculateTotal john 100.0M = 90.0M
let assertMary = calculateTotal mary 99.0M = 99.0M
let assertRichard = calculateTotal richard 100.0M = 100.0M
let assertSarah = calculateTotal sarah 100.0M = 100.0M

It's nice but we still have some primitives where we should have domain concepts (Spend and Total). I would like the signature of the calculateTotal function to be Customer -> Spend -> Total. The easiest way to achieve this is to use type abbreviations. Add the following below the Customer type definition:

type Spend = decimal
type Total = decimal

We have two approaches we can use our new type abbreviations and get the type signature we need: Explicitly creating and implementing a type signature (which we did in Part 6) or using explicit types for the input/output parameter. Type signature first:

type CalcualteTotal = Customer -> Spend -> Total

let calculateTotal : CalculateTotal =
    fun customer spend ->
        let discount = 
            match customer with
            | EligibleCustomer _ when spend >= 100.0M -> spend * 0.1M
            | _ -> 0.0M
        spend - discount

Followed by explicit parameters:

let calculateTotal (customer:Customer) (spend:Spend) : Total =
    let discount = 
        match customer with
        | EligibleCustomer _ when spend >= 100.0M -> spend * 0.1M
        | _ -> 0.0M
    spend - discount

Either approach gives us the signature we want. I have no preference for the style as both are useful to know, so we will use the explicit parameters for the rest of this post.

There is a potential problem with type abbreviations; As long as the underlying type matches, I can use anything, not just Spend. Imagine that you had a function that takes three string arguments. There is nothing stopping you supplying the wrong value to a parameter.

type CustomerName = {
    First : string
    Middle : string
    Last : string
}

let createName first middle last =
    { First = first; Middle = middle; Last = last }

let name = createName "????" "Russell" "Ian"

Of course we can write tests to prevent this from happening but that is additional work. Thankfully there is a way that we can stop this in F# - The single case discriminated union!

type Spend = Spend of decimal

It is convention to write it this way but it is also:

type Spend =
| Spend of decimal

You will notice that the calculateTotal function now has errors. We can fix that by deconstructing the Spend parameter value in the function:

let calculateTotal (customer:Customer) (spend:Spend) : Total =
    let (Spend value) = spend
    let discount = 
        match customer with
        | EligibleCustomer _ when value >= 100.0M -> value * 0.1M
        | _ -> 0.0M
    value - discount

If you replace all of your primitives and type abbreviations with single case discriminated unions, you cannot supply the wrong parameter as the compiler will stop you.

The next improvement is to restrict the range of values that Spend can accept since very few domain values will be unbounded. We will restrict Spend to between 0 and 1000. To do this we are going to add a ValidationError type, prevent the direct use of the Spend constructor and add a new module to handle the domain rules and the creation of the instance.

type ValidationError = 
| InputOutOfRange of string

type Spend = private Spend of decimal

module Spend =
    let create input =
        if input >= 0.0M || input < 1000.0M then
            Ok (Spend input)
        else
            Error (InputOutOfRange "You can only spend between 0 and 1000")
    let value (Spend input) = input

The name of the module must match the name of the type. We need to amke some changes to get the code to compile. Firstly we change the first line of the calculateTotal function to use the new value function:

let calculateTotal (customer:Customer) (spend:Spend) : Total =
    let value = Spend.value spend
    let discount = 
        match customer with
        | EligibleCustomer _ when value >= 100.0M -> value * 0.1M
        | _ -> 0.0M
    value - discount

We also need to fix the asserts. To do this, we are going to add a new helper function:

let doCalculateTotal name amount =
    match Spend.create amount with
    | Ok spend -> calculateTotal name spend
    | Error ex -> failwith (sprintf "%A" ex)

let assertJohn = doCalculateTotal john 100.0M = 90.0M
let assertMary = doCalculateTotal mary 99.0M = 99.0M
let assertRichard = doCalculateTotal richard 100.0M = 100.0M
let assertSarah = doCalculateTotal sarah 100.0M = 100.0M

As a piece of homework, try to replace the validation we did in Part 8 with what you have learnt in this post.

The final change that we can make is to move the discount rate from the calculateTotal function to be with the Customer type. The primary reason for doing this would be if we needed to use the discount in anther function:

type Customer =
    | EligibleCustomer of RegisteredCustomer
    | RegisteredCustomer of RegisteredCustomer
    | Guest of UnregisteredCustomer
    with 
        member this.Discount =
            match this with
            | EligibleCustomer _ -> 0.1M
            | _ -> 0.0M

This also allows us to simplify the calculateTotal function:

let calculateTotal (customer:Customer) (spend:Spend) : Total =
    let value = Spend.value spend
    let discount = if value >= 100.0M then value * customer.Discount else 0.0M
    value - discount

Whilst this looks nice, it has broken the link between the customer type and the spend. If you remember, the rule is a 10% discount if an eligible customer spends 100.0 or more. Let's have another go:

type Customer =
    | EligibleCustomer of RegisteredCustomer
    | RegisteredCustomer of RegisteredCustomer
    | Guest of UnregisteredCustomer
    with 
        member this.CalculateDiscount(spend:Spend) =
            match this with
            | EligibleCustomer _ -> 
                let value = Spend.value spend
                if value >= 100.0 then 0.1M else 0.0M
            | _ -> 0.0M

You will probably need to move some of your types to before the Customer declaration in the file.

We now need to modify our calculateTotal function to use our new function:

let calculateTotal (customer:Customer) (spend:Spend) : Total =
    let value = Spend.value spend
    let discount = value * customer.CalculateDiscount spend
    value - discount

To run this code, you will need to load all of it back into FSI. Your tests should still pass.

Is this approach better? It's hard to say but at least you are now aware of some of the options.

Final Code

type RegisteredCustomer = {
    Id : string
}

type UnregisteredCustomer = {
    Id : string
}

type ValidationError = 
| InputOutOfRange of string

type Spend = private Spend of decimal

module Spend =
    let create input =
        if input >= 0.0M || input < 1000.0M then
            Ok (Spend input)
        else
            Error (InputOutOfRange "You can only spend between 0 and 1000")
    let value (Spend input) = input

type Total = decimal

type Customer =
    | EligibleCustomer of RegisteredCustomer
    | RegisteredCustomer of RegisteredCustomer
    | Guest of UnregisteredCustomer
    with 
        member this.CalculateDiscount(spend:Spend) =
            match this with
            | EligibleCustomer _ -> 
                let value = Spend.value spend
                if value >= 100.0 then 0.1M else 0.0M
            | _ -> 0.0M

let calculateTotal (customer:Customer) (spend:Spend) : Total =
    let value = Spend.value spend
    let discount = value * customer.CalculateDiscount spend
    value - discount

let john = EligibleCustomer { Id = "John" }
let mary = EligibleCustomer { Id = "Mary" }
let richard = RegisteredCustomer { Id = "Richard" }
let sarah = Guest { Id = "Sarah" }

let doCalculateTotal name amount =
    match Spend.create amount with
    | Ok spend -> calculateTotal name spend
    | Error ex -> failwith (sprintf "%A" ex)

let assertJohn = doCalculateTotal john 100.0M = 90.0M
let assertMary = doCalculateTotal mary 99.0M = 99.0M
let assertRichard = doCalculateTotal richard 100.0M = 100.0M
let assertSarah = doCalculateTotal sarah 100.0M = 100.0M

Conclusion

In this post we have learnt about the single case discriminated union to enable us the restrict the range of data that we support for a parameter and that we can add helper functions/properties to discriminated unions.

In the next post we will look at object programming.

If you have any comments on this series of posts or suggestions for new ones, send me a tweet (@ijrussell) and let me know.

Zurück
Zurück

My Workflows at Trustbit During the Quarantine

Weiter
Weiter

Introduction to Functional Programming in F# – Part 8