F# Basics – Result<'a>: or How I Learned to Stop Worrying and Love the Bind
This year, for FsAdvent, I’m taking a request and am going to write about embracing the happy path while writing F#. When coming to F# or other functional programming languages, one of the hurdles I see people face is often rethinking how they approach problems. While many posts talk about how to do this, I want to focus on one of the huge advantages – less worry and stress about the code working correctly.
For this post, I’m going to use a small example task – parsing a file on disk and summarizing some information from it. In this case, we’ll do something simple – read a CSV log file of “events” that occurred, and get the range of ID numbers from the file. Here is our input file:
Our task will be to read this file, parse it, and get the range of IDs being used. In this case, we’ll end up with 2 numbers as our result – 1928 and 1939.
When working in F#, I often start by describing the data I’ll be using, as well as the result I want to get or manipulate. Here, each row represents three things; a date, an ID, and a description of the event. We want to eventually get two numbers, a starting ID and an ending ID. In addition, we know there are a few things that could go wrong – the file might not exist, there might be no data in the file, or the data might be malformed. I’m going to start by defining a couple of types to help with this:
My goal when writing this utility will be to always write in a “happy path”. Instead of focusing on handling bad data or errors, I’m going to try to break everything up into small steps, each a function, that does one thing. I’m always going to assume my inputs are good, and my output will always either be good result or an Issue from above. Basically, I never want to think about “bad data” – I just want to write code that does something as I go.
In F#, there is a type that helps with this significantly – Result. The Result type is a discriminated union with two options; an Ok result, or an Error.
We’ll start by opening our file. In this case, we want a function that takes a filename and returns a Stream we can use for reading. File IO is one place where exception handling is nearly required, so I’m using that to create my error case:
This function will follow a common pattern – it takes an input (filename) and creates a Result: val openFile : filename:string -> Result
To parse the file, I’m going to use the CsvHelper library. This will allow me to not think about parsing the “hard parts” of CSV, like strings with embedded commas or quotes. Now we need a small function to take a stream from our openFile and convert it to a CsvReader:
Here, we know, if we have a valid stream, we’ll get a valid reader, so we can just write a simple Stream -> CsvReader
helper. We can put these together with Result.map, which allows us to take an “Ok” result and use it’s value to make a new result.
Running in FSI, with a good filename, gives us val csv : Result = Ok CsvHelper.CsvReader
. With a bad filename, you get:
val csv : Result =
…
Error
(IO
("Could not open file",
System.IO.FileNotFoundException: Could not find file
Now that our file is opened, we can move onto parsing. Again, we’ll assume we have a good input (our reader), and just write routines to just parse. In this case, I’m going to write a function to parse a single row and create an option, and a separate function to parse until we receive None, which will return our Result.
This does get a little uglier with the routine to read all of the rows. We want to make sure to close our file (we effectively pass ownership), so we need the try/finally to dispose of it anytime our routine to open succeeded. We’ll use a simple try/with to handle parsing errors. Using Seq.initInfinite and Seq.takeWhile allows us to “loop” through our data until we return None:
Our final step is to reduce the Event list to our two numbers:
Now that we have all of the pieces, we can stream these together. We will use two functions from the Result module in the core libraries to string these together – Result.map and Result.bind.
Whenever we have a function that doesn’t produce an error case, we will use map. When we have a function that takes our input and might need an error case, we will use bind. In our case, openFile and getIds both “always work” if their input are good, so we can map them. readRowsAndClose can map the data through to a list, but can also create an error case, so we will use bind. When stringing these together, we end up with:
When run via FSI, and given the input file above, this now produces:
val result : Result = Ok { Start = 1928
End = 1939 }
If we pass an invalid filename, we get a nice error:
val result : Result =
Error
(IO
("Could not open file",
System.IO.FileNotFoundException: Could not find file...
If we pass a good filename, but the file has bad data, that is also handled:
val result : Result =
Error
(DataMalformed
("Unable to parse data",
CsvHelper.TypeConversion.TypeConverterException: The conversion cannot be performed.
Text: '193d3'
By breaking this into pieces, and using Result.map and Result.bind, we were able to parse this in stages, where every stage could assume everything was “good”, and our errors propagate through cleanly at the end. Each step in the chain only has to focus on the task at hand.