Basics 19 - Double-Lifted Data

What is Double-Lifted Data

Consider this:

let opt: int option = Some 1
let res: Result<int option, exn> = Ok opt
  1. opt is an int option, which means value 1 is packed in option container

  2. opt is then packed in a Result

  3. Simply put, 1 is double-lifted

While any combination of double-lifting is theoretically possible, some of the cases you will practically and frequently encounter are:

  1. Result<'T option, 'TError>:

    1. Indicates the result of an operation that may fail

    2. In Ok case, you may or may not have data

    3. In Error case, you have an exception

    4. Example: executing a DB query that gets one result/row/document. There are various outcomes:

      1. DB operation failed, Error case

      2. DB operation succeeded, found the record, Ok case -> Some case

      3. DB operation succeeded, no record found, Ok case -> None case

  2. Result<'T seq, 'TError> or Result<'T list, 'TError> or Result<'T array, 'TError>:

    1. Indicates the result of an operation that may fail

    2. In Ok case, you will get a seq/list/array of items

    3. In Error case, you have an exception

    4. Example: executing a DB query that gets multiple results/rows/documents. There are various outcomes:

      1. DB operation failed, Error case

      2. DB operation succeeded, found the records, Ok case -> 'T seq/list/array

        1. In Ok case, there may be 0 or more items in 'T seq/list/array

Problem with Double-Lifted Data

Consider this:

let res: Result<int option, exn> = Ok (Some 1)

let add1 num = num + 1
  1. We have add1 function which can be used for mapping

  2. How do we use add1 with res?

  3. Result.map add1 res won't work. In this case Result.map expects a function (mapper) that operates on int option, not int.

  4. There is no point in changing add1; add1 is our business logic, typically reusable

There are various ways to solve this issue.

Wrapper Function or Lamba Expression

The wrapper function add1Wrapper (or the lambda expression) takes int option as input:

let add1 num = num + 1
let add1Wrapper opt = Option.map add1 opt

Result.map add1Wrapper res

//// OR ////

Result.map (fun opt -> Option.map add1 opt) res

The problem with this approach is that the burden is now yours... for how many such functions can you create wrappers or lambda expressions.

Lifted Function

Remember map function can be used as a function lifter. Function liftedAdd1 is the lifted version of add1:

let add1 num = num + 1

let liftedAdd1 = Option.map add1

Result.map liftedAdd1 res

//// OR ////

let x = Result.map (Option.map add1) res

The problem with this approach, once again, is that the burden is yours... for how many such functions can you create lifted versions.

Let's look at another approach.

Functions that are aware of Double-Lifted Data

module Result =
    let optMap mapper result = Result.map (Option.map mapper) result
    let optBind binder result = Result.map (Option.bind binder) result

Here we have created two functions optMap and optBind, and put those in the Result module. This is done for consistency, you can call Result.optMap and Result.optBind.

let add1 num = num + 1

let add1ifEven num =
    if num % 2 = 0 then Some(num + 1) else None

let res: Result<int option, exn> = Ok(Some 1)

Result.optMap add1 res |> printfn "%A" // Ok (Some 2)

Result.optBind add1ifEven res |> printfn "%A" // Ok None

Concluding Remarks

  1. Dealing with double-lifted data isn't the most straight-forward

  2. For rare cases, you may use lambda expressions or lifted functions

  3. Best to create a library of functions that are aware of double-lifted data, as we will in coming articles

If you have reached so far, congratulations.

Keep reading!