Home > Enterprise >  F# How to flatten nested Result types mixed with other types
F# How to flatten nested Result types mixed with other types

Time:10-14

I'm currently working on a repository of methods for a Domain Driven Design project.

The method for getting a DTO from the repository returns the following type: Async<Result<DTO.Worker option, exn>>, and then I run the following code to transform that DTO into a Domain.Worker:

methodResult
|> Async.map ((Result.map (Option.map DTO.Worker.toDomain)))

The Domain.Worker record type has a discriminated union as one of it's values, and we can't use them directly in the database, so there's an extra step outside of the domain model that maps the discriminated union to numbers so we can use save that into the db.

module Domain

type WorkerStatus =
| Status1
| Status2
| Status3

type Worker = {
    ...
    Status : WorkerStatus
}

The issue is that in the DTO.Worker.toDomain method I'm using pattern matching for mapping those numbers back to the discriminated union:

module DTO

type Worker = {...}
with
    static member toDomain (dbo : Worker) : Domain.Worker = {
            ...
            Domain.Worker.Status =
                match dbo.Status with
                | 1 -> Domain.WorkerStatus.Status1
                | 2 -> Domain.WorkerStatus.Status2
                | 3 -> Domain.WorkerStatus.Status3
                | _ -> raise (exn "data in the database has the wrong format")
        }

I could get away with raising exceptions while we were starting the project but now that I'm cleaning up the code I need to wrap this method in a result too, and that raises two questions:

  1. How to wrap the code in a result following F# standars? I'm still learning F# so the only way I can think of is creating a mutable bool variable at the start of the toDomain method, and changing it if we hit any errors in the pattern matching, then returning the DTO transformed into a Domain model or an error based on that boolean. I also tried surrounding the method with a try...with... block and leave the raise exn, but my colleague said it wasn't a good solution too.
  2. How can I flatten the new nested Result type? if we change the code so the toDomain method returns a Result instead of the Domain object, we'll end up with an Async<Result<Result<Domain.Worker, exn> option, exn>> type, and that's not useful as we are expecting Async<Result<Domain.Worker option, exn>> everywhere else in the code.

CodePudding user response:

Short-term suggestion

I think you've painted yourself into a corner with these type signatures, so there's no simple solution, but here's the least invasive approach I can think of that seems consistent with your requirements.

First, modify toDomain so it returns a Result instead of throwing an exception:

        static member toDomain (dbo : Worker) =
            let statusResult =
                match dbo.Status with
                | 1 -> Ok Domain.WorkerStatus.Status1
                | 2 -> Ok Domain.WorkerStatus.Status2
                | 3 -> Ok Domain.WorkerStatus.Status3
                | _ -> Error (exn "data in the database has the wrong format")
            statusResult
                |> Result.map (fun status ->
                {
                    //...
                    Domain.Worker.Status = status
                })

Now, as you mentioned, the problem is that you have a Result<Option<Result<'a, exn>>, exn> when you want a Result<Option<'a>, exn>, so we need a conversion function:

let convert = function
    | Ok (Some (Ok x)) -> Ok (Some x)
    | Ok (Some (Error exn)) -> Error exn
    | Ok None -> Ok None
    | Error exn -> Error exn

When you're transforming your DTO into a domain value, you then need:

methodResult
|> Async.map ((Result.map (Option.map DTO.Worker.toDomain)) >> convert)

I'm not saying this is a good idea, but it should at least allow you to untangle the knot for now.

Long-term suggestion

I think it's unwise to mix Result and Option types the way you have. If I understand your design correctly, a value of Ok None currently means that the data access layer executed successfully, but didn't find the requested object. I can see why you ended up with this design, but I think it's more trouble than it's worth. Instead, I would suggest that every operation return something like Result<'a, Error> instead, where the Error type could be:

type Error = Exn of exn | NotFound

So, for example, Ok None would then become Error NotFound instead. Once you make that change, you stay within a single Result monad, and F# can make your life a lot easier. For example, you could then use a computation expression like this:

let getDomainResult queryParams =
    result {
        let! dto = getDto queryParams
        return! convertToDomain dto
    }

(Note: I've ignored Async here for simplicity, but AsyncResult might work even better for you. In either case, just stay away from Option types in the same workflow. There's nothing you can do with Option that can't be done with Result.)

  • Related