How to structure result lwt code to avoid nesting hell

We face the same difficulty in the Tezos code-base. I don’t think there is a well known, very satisfying solution to that. We have syntactically distinct operators (bind, return, fail) for the different monads so our problem is slightly different.

I’d recommend that you define a few additions to Stdlib.Result to help with moving from one monad to the other. E.g., Result.recover : ('b -> 'a) -> ('a, 'b) result -> 'a will help you go from Lwt+result into Lwt only.

Also, whilst @CraigFe has given a very good answer already, there are some things he did not mention in text. So here are a few comments about his code.

  • Pulling out the common pattern into a separate function is useful for readability, but you end up also pulling an error literal (A, B, C) out of a branching pattern. If you are in a more realistic case where the error is actually an evaluated expression you will evaluate it even in the success path. You can either (a) avoid pulling this into its own function or (b) wrap it in a lazy or a closure (fun () -> …).

  • The Lwt_result.map_err is doing the heavy lifting here. It is actually sufficient to avoid nesting altogether. (You could also use Result.iter_error to separate the logging from the erroring.)

    let () =
      let* a = MyService.get_a () in
      let* a = Lwt_result.map_err (fun e -> ErrorService.log "error with a" e; Error A) in
      <same for b and c>
    

    One of the issue you might encounter is that Result and Lwt_result have a different set of functions (e.g., Result.iter_error does not have a a counterpart in Lwt_result and Result.map_error’s counterpart is spelled Lwt_result.map_err). If you start relying on these modules a lot, I’d recommend recoding your own to have a uniform view of results.

  • Do use List.compare_length_with when comparing lengths: it returns early if the list is longer than the integer.

4 Likes