Understanding map and bind with F# options
Tags: fsharp
Published: 2023-11-25 17:51:39

The option is a very frequently used type in F# programs. We use options to represent values that may (Some) or may not (None) be present. When reading in data from an external source we can't be sure that every field is filled in with a value so we'll represent these as options. Some of the functions we try to perform on values may not be able to produce an instance of the desired type and so we'll return an option of the type. For example, we can't be sure that the string we want to convert to an int is valid for that conversion.

It's simple to work with options using the match expression. When learning F# it can be helpful to start with match expressions since they're easy to understand, especially for the simple option type. However, when the program starts to grow it can become very tedious to write a match expression for every interaction with an option. The other problem with match expressions is that they're not helpful when we want to use a pipeline to perform a series of steps.

The option module in F# has several pipeline friendly functions that be used instead of match expressions, they include `Option.map`, `bind`, and `defaultValue`. It took a while for me to grasp how best to use these functions. They're quite simple at first glance but it just wasn't obvious how to incorporate them into my programs.

Let's look a few examples where these Option module function can make working with options much easier.

For the first example, we'll read in CSV data, parse it, and create an instance of a record type for each line. This common scenario can lead to many places where we'll need to work with options. We'll also see the problem that arises when we start trying to compose functions that return options with other functions that don't expect options as input. 

Here's the first try to perform two of the steps. Split up the CSV data into an array of lines and then split each line into an array of the fields. So, we'd like to end up with an array of arrays.
```fsharp
let productsRawData =
    "id,description,rating,price
123,Product 1,3,99.95
345,Product 2,,23,95
456,Product 3,3,
567,Product 4,,
678,Product 5,2,23.9a
789,  ,4,25"

let csvToLines (csvData:string) : string array option =
    if csvData.Length <= 1 then
        None
    else
        Some (csvData.Split("\r\n") |> Array.skip 1)
    
let lineToFields (line:string) : string array =
    let fields = line.Split(",")
    fields
    
let results =
    productsRawData
    |> csvToLines
    |> Array.map lineToFields // Won't compile 

printfn $"%A{results}"
```

The `csvToLines` function returns a `string array option` because the CSV data may be empty or have only a header. In those cases we'll return `None`. Once we have the array of lines we want to call `lineToFields` for each of them using `Array.map`. This can't compile however because `Array.map` expects an `array` and we're trying to pass it an `array option`.

Let's fix this first using a match expression followed by the alternative `Option.map`
```fsharp
let lines =
    productsRawData
    |> csvToLines

let results =
    match lines with
    | Some l -> Some(Array.map lineToFields l)
    | None -> None

printfn $"%A{results}"

Some
  [|[|"123"; "Product 1"; "3"; "99.95"|];
    [|"345"; "Product 2"; ""; "23"; "95"|];
    [|"456"; "Product 3"; "3"; ""|];
    [|"567"; "Product 4"; ""; ""|];
    [|"678"; "Product 5"; "2"; "23.9a"|];
    [|"789"; "  "; "4"; "25"|]|]
```
This works! However, we lost the nice concise pipeline syntax and had to break the problem up into an intermediate step. Let's try with `Option.map`.

```fsharp
let results =
    productsRawData
    |> csvToLines
    |> Option.map (Array.map lineToFields)
    
printfn $"%A{results}"

Some
  [|[|"123"; "Product 1"; "3"; "99.95"|];
    [|"345"; "Product 2"; ""; "23"; "95"|];
    [|"456"; "Product 3"; "3"; ""|];
    [|"567"; "Product 4"; ""; ""|];
    [|"678"; "Product 5"; "2"; "23.9a"|];
    [|"789"; "  "; "4"; "25"|]|]
```

Great! We're getting the exact same result, we get to keep the pipeline of functions going, and we can still handle the option. Why does this work? Take a look at the source code for `Option.map` next to the source for our match expression solution.

```fsharp
match lines with
    | Some l -> Some(Array.map lineToFields l)
    | None -> None
    
// Option.map source
let map mapping option =
    match option with
    | None -> None
    | Some x -> Some(mapping x)
```

Very close. `Option.map` is performing the exact same matching logic that we were doing inline. The difference is that the function we need to call is passed in as the `mapping` parameter and `Option.map` calls it for us if the option has a value. Notice this bit `Some(mapping x)`. Because the mapping function we pass in doesn't return an option type, we have to convert it to a option to balance with `None` case. The code wouldn't compile otherwise.

Let's finish the remaining steps of parsing the fields and converting each array of fields into a `Product` record. Along the way there are a few more cases where the `Option` module functions can make this easier. We'll use both `Option.map` and `Option.defaultValue` here.
```fsharp
let parseStringField (field: string) : string option =
    match field with
    | field when String.IsNullOrWhiteSpace(field) -> None
    | _ -> Some(field.Trim())

let parseIntField (field: string) : int option =
    let success, value = Int32.TryParse(field)
    if success then Some value else None

let parseDecimalField (field: string) : decimal option =
    let success, value = Decimal.TryParse(field)
    if success then Some value else None

let createRecord (fields: string array) : Product =
    if fields.Length < 4 then
        None
    else
        Some
            { Id =
                fields[0]
                |> parseIntField
                |> Option.defaultValue 0
              Description =
                fields[1]
                |> parseStringField
                |> Option.defaultValue ""
              Rating =
                fields[2]
                |> parseIntField
                |> Option.defaultValue 0
              Price =
                fields[3]
                |> parseDecimalField
                |> Option.defaultValue 0m }
    |> Option.defaultValue Product.Default

let results =
    productsRawData
    |> csvToLines
    |> Option.map (Array.map lineToFields)
    |> Option.map (Array.map createRecord)
    |> Option.map (Array.filter (fun x -> x.Id > 0)) // Exclude default records
    |> Option.defaultValue [||]
```

So, the pipeline now includes several steps. The functions we want to use including `Array.map` and our custom functions don't expect options as inputs nor do they return options. But, using `Option.map` we can bridge that without breaking up the flow of the pipeline. If we attempted all the steps with inline match expressions this would get really long and hard to follow. The `Option.defaultValue` function is another pipeline friendly function that was very helpful for converting our option values back to regular values.

The pattern to look for where `Option.map` can be helpful is when you're trying to compose functions where the first function returns an option but the second function takes a non-option input and returns a non-option output.

What about functions that take a regular input value and return an option. These can also be composed in a pipeline and the `Option.bind` function is used in that case. `Option.bind` is very similar to `Option.map`. Look at the source code for them side by side.

```fsharp
let bind binder option =
    match option with
    | None -> None
    | Some x -> binder x
       
let map mapping option =
    match option with
    | None -> None
    | Some x -> Some(mapping x)
```

Map performs the one extra step of wrapping the results from the mapping function into a `Some`. Bind omits that step since the binder function already returns an option. That's the reason that we select `map` for functions that don't return an option and `bind` for functions that do.  

Let's look at a simple example where we want to perform a couple of parsing steps based on a product description. The first function has to return an option because the value we're trying to extract may not be present. The next function will take the product code we extract and map it to a category. Again, we can't just wire these up directly in a pipeline because the option output from the first function is not compatible with the string input expected by the second function.

```fsharp
type ProductCategory =
    | Electronics
    | Timepieces
    | Kitchen

let tryGetProductCode (description:string) : string option =
    let options = (RegexOptions.Multiline ||| RegexOptions.IgnoreCase)
    let mtch = Regex.Match(description, "(\w{1}-\d{4}-\d{2})", options)
    if mtch.Success then
        Some mtch.Groups[0].Value
    else
        None

let tryGetCategory (productCode:string) : ProductCategory option =
    match productCode.Substring(0,1) with
    | "K" -> Some Kitchen
    | "T" -> Some Timepieces
    | "P" -> Some Pets
    | "E" -> Some Electronics
    | _ -> None
        
let someCategory =
    "Citizen Men's Eco-Drive Sport
Luxury Endeavor Watch: T-1933-32"
    |> tryGetProductCode
    |> Option.bind tryGetCategory
    
printfn $"%A{someCategory}"
// Some Timepieces
    
let noCategory =
    "SAMSUNG 43-Inch Class QLED 4K Q60C Series Quantum HDR
Dual LED, Object Tracking Sound Lite, Q-Symphony,
Motion Xcelerator, Gaming Hub"
    |> tryGetProductCode
    |> Option.bind tryGetCategory
    
printfn $"%A{noCategory}"
// None
```

The `Option.map`, `bind`, and `defaultValue` functions are very helpful in many day to day programming tasks. I'm finding that I use them quite frequently and rely much less on inline match expressions. I also find code written with a combination of pipeline steps and these option functions is much easier to read. The intention of the program doesn't become lost in the details of manually managing all the options.