DEV Community

Discussion on: Grokking Applicative Validation

 
deyanp profile image
Deyan Petrov

Thanks for your patience, Matt! I think it will be over after you read the below ;)

I understand that you want to extend the output set of possible values of the function in order to generalize its interface to theoretically handle more than 1 error.

The fact of the matter is that the current version of the function(s) is returning only a single error though, and a Result<_, string list> may not be really justified by the function when looking at it in isolation.

The Result<_, string list> is solely required so that this function can be used in a parent/"orchestration" function utilizing applicative validation. So the "innocent" function knows this parent/orchestration context now ...

Let me ask you a similar question - imagine you have an orchestration/workflow function returning Result<_, OrchestrFunctionErrorDU>, and invoking 2 simple/reusable in different context "worker" functions. Which variant of the 2 below would you choose?

OPTION 1

let worker1a (someParam:bool) : Result<string, string> = 
    if someParam then Ok "all good1" else Error "sth wrong1a"

let worker1b (someParam:bool) : Result<string, string> = 
    if someParam then Ok "all good2" else Error "sth wrong1b"

type OrchestrFunctionErrorDU =
    | HighLevelError1 of string
    | HighLevelError2 of string

let orchestrator1 () : Result<string, OrchestrFunctionErrorDU> = 
    result {
       let! x = worker1a true |> Result.mapError OrchestrFunctionErrorDU.HighLevelError1
       let! y = worker1b false |> Result.mapError OrchestrFunctionErrorDU.HighLevelError2
       return "all good"
    }
Enter fullscreen mode Exit fullscreen mode

OPTION 2

type OrchestrFunctionErrorDU =
    | HighLevelError1 of string
    | HighLevelError2 of string

let worker2a (someParam:bool) : Result<string, OrchestrFunctionErrorDU> = 
    if someParam then Ok "all good1" else "sth wrong2a" |> OrchestrFunctionErrorDU.HighLevelError1 |>Error

let worker2b (someParam:bool) : Result<string, OrchestrFunctionErrorDU> = 
    if someParam then Ok "all good2" else "sth wrong2b" |> OrchestrFunctionErrorDU.HighLevelError2 |>Error

let orchestrator2 () : Result<string, OrchestrFunctionErrorDU> = 
    result {
       let! x = worker2a true
       let! y = worker2b false
       return "all good"
    }
Enter fullscreen mode Exit fullscreen mode

The difference is that in Option 1 the worker functions do not know about the higher level context and its error DU, and return some primitive error type (in this case string). In Option 2 they do know about the higher-level error DU, and they use it by pretending (imho) to be able to return both error cases, but of course returning only one of them.

I think you would choose option 2 ... I would choose option 1 but still trying to understand why people would choose option 2 ...

P.S. if you need the result CE (from Scott Wlaschin) to make the above work in fsx here it is:

//==============================================
// Computation Expression for Result
//==============================================

[<AutoOpen>]
module ResultComputationExpression =

    type ResultBuilder() =
        member __.Return(x) = Ok x
        member __.Bind(x, f) = Result.bind f x

        member __.ReturnFrom(x) = x
        member this.Zero() = this.Return ()

        member __.Delay(f) = f
        member __.Run(f) = f()

        member this.While(guard, body) =
            if not (guard()) 
            then this.Zero() 
            else this.Bind( body(), fun () -> 
                this.While(guard, body))  

        member this.TryWith(body, handler) =
            try this.ReturnFrom(body())
            with e -> handler e

        member this.TryFinally(body, compensation) =
            try this.ReturnFrom(body())
            finally compensation() 

        member this.Using(disposable:#System.IDisposable, body) =
            let body' = fun () -> body disposable
            this.TryFinally(body', fun () -> 
                match disposable with 
                    | null -> () 
                    | disp -> disp.Dispose())

        member this.For(sequence:seq<_>, body) =
            this.Using(sequence.GetEnumerator(),fun enum -> 
                this.While(enum.MoveNext, 
                    this.Delay(fun () -> body enum.Current)))

        member this.Combine (a,b) = 
            this.Bind(a, fun () -> b())

    let result = new ResultBuilder()
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
choc13 profile image
Matt Thornton • Edited

Yes, in this case I would take option1, because OrchestrFunctionErrorDU has nothing to do with the lower level functions. However, I don't think this is the same case, although it might appear to be.

My primary motivation for choosing to use Result<'a, 'e list> is not because the parent function needs an error list in order for apply to work, but instead because it's a good representation of a validation computation in its own right. The argument being that when validating any data it's not unreasonable to expect to encounter several errors.

It just also happens that in this case it does make the parent composition easier too, but that's a secondary reason for doing it that way.

If I were actually modelling the errors then I would likely use a DU to describe the errors cases for each field and then unify those with a DU at the parent level (similar to your example). In that case I would only lift those errors into the parent DU within the parent function, because as you rightly say the child validation function should have no knowledge of that parent level DU type.

So I normally expect to do some error mapping in the parent function, but in the case of whether or not to return a list of errors I choose to use a list in the child function because above all else I believe it is a better api for that function, which allows that function to evolve more independently in the future.

I guess another way to think about this is the locality of any future changes. If I were to change the validateChild function in the future and it now started returning several errors instead of one I would be forced to also go and fix validateParent which called that. However, in the scenario where I'm unifying errors in parent ParentErrorDU type then that is something that is defined at the same abstraction level as validateParent (probably in the same F# module) and so if I change that type then I'm going to have to fix the error mappings in validateParent but I'm OK with that because that is a change at the same level of abstraction. I don't however have to go and change any of the child validation functions just because ParentErrorDU changed.

So by returning a list of errors from the child and doing any parent level DU mapping in the parent we've achieved high cohesion and low coupling even though they look like they're contradictory design choices.