String Calculator Kata in F# - fall of exceptions
Today we are going to implement handling negative values and values that are greater than 1000. It's step 5 and 6 from the kata description:
- calling Add with a negative number will throw an exception "negatives not allowed" - and return passed negative values
- ignore numbers greater than 1000, so 1002 + 1 equals 1
This is a third part of a series:
Finally, we can try to handle exceptions in a functional way.
Spoiler alert! There won't be any exceptions.
Since last time we have an elegant pipeline.
let sumNumbers = parseInput >> convertToNumbers >> Seq.sum
Because we use composition, we can pass the data from one function to another. Additionally, it makes it open for extension by adding a new function to our current flow.
Back to handling negative values, we start with a test:
[<Fact>]
let ``Cannot add negative values`` =
let result = Add "-3, 1"
Assert.Equal(Error "-3", result)
When adding a negative value, we expect that we get that value back wrapped in an Error.
That strange prefix inside the assertion can be surprising, right?
It means an error, obviously, but what kind of error is this?
That is, my friends, an elegant way of handling exceptions. Without throwing an exception at all!
Take a look at Add function:
let Add (numbers: string): Result<int, string> =
sumWithFilter numbers
Notice the return type of the function. From int
, it turned to Result<int,string>.
Why? Because now it can return one of two values. An integer when everything went fine or a string when we tried to add negative values.
Stop for a moment here. What's wrong with throwing an exception? Everyone does that all the time! Why can't we follow the same path?
But is it an exceptional situation? I don't think so. It's a part of our domain!
We know that we will deal with a negative value. Also, we know how to handle such a case. Let's make it explicit with the Result
type.
Using this approach, we make it evident that our function can have two mutually exclusive outputs - one for success and an error.
You can read more on this on a great @ScottWlaschin blog.
Having the Result
behind us, we can attach to our pipeline a filter for negative values like so:
let sumWithFilter =
parseInput
>> convertToNumbers
>> findNegativeValues
>> sum
and findNegativeValues implementation:
let findNegativeValues (numbers: Numbers) =
let negativeValues = Array.filter (fun x -> x < 0) numbers
match Array.isEmpty negativeValues with
| true -> Ok numbers
| false -> Error negativeValues
That is the place where we elevate our pipeline into the Result
world.
When we got any negative numbers in the input, we return an Error
and said values. In the other case, we produce an OK
of input.
Because of elevating to the Result
land, we have to modify our sum function:
let sum (numbersWithNegatives: Result<Numbers, int[]>) =
match numbersWithNegatives with
| Ok n -> Ok (Seq.sum n)
| Error e -> e |> Array.map string |> String.concat "," |> Error
Notice the pattern matching here. If we got only positive values, we return an OK
of summed numbers. When we got any negative values, we produce an Error
with concatenated negative values.
This way, we solved the issue with negative values. We didn't use any exceptions but achieved the same result. The critical point is that our Add
function now returns an OK
or an Error
We have one more thing to do. It's step 6 and ignoring numbers greater than 1000. This one is relatively easy. We have to filter out any number that is greater than 1000 with this function.
let ignoreValuesGreaterThan1000 (numbers: Numbers) =
Array.filter (fun x -> x < 1000) numbers
And add it to our pipeline.
let sumWithFilter =
parseInput
>> convertToNumbers
>> ignoreValuesGreaterThan1000
>> findNegativeValues
>> sum
Full code is available, as always, on my github.
In the next part, we will implement all three remaining steps. See you there!