elm-verify icon indicating copy to clipboard operation
elm-verify copied to clipboard

How best to express that a value is either one thing or another?

Open michaeljones opened this issue 8 years ago • 0 comments

I have a string in my model that I'd like to either be empty or for it to represent an int. It represents a numerical limit on something and no entry (empty string) means no limit. It also means that if it is an int, it should ideally be a positive int.

The validation would convert from a String to a Maybe Int.

I think it is reasonable to approach this with custom. I ended up with something like:

type alias EditableCountConstraint =
    { minCount : String
    , maxCount : String
    }


type alias CountConstraint =
    { minCount : Maybe Int
    , maxCount : Maybe Int
    }

test : V.Validator String EditableCountConstraint CountConstraint
test =
    let
        validator value =
            if String.isEmpty value then
                Ok Nothing
            else
                case String.toInt value of
                    Ok int ->
                        if int > 0 then
                            Ok <| Just int
                        else
                            Err [ "ConstraiPFDnt should be greater than zero" ]

                    Err _ ->
                        Err [ "Constraint should be an integer" ]
    in
    V.ok CountConstraint
        |> V.custom (.minCount >> validator)
        |> V.custom (.maxCount >> validator)

This is fine, I suspect. And gives granular control over the error messages. But as I'm still clearly somewhat stuck in the mindset of Json.Decode, I initially wanted to have some kind of oneOf based API. Like (pseudo-code):

            oneOf "Constraint must be empty or a positive integer" <|
                    [ String.Verify.Extra.empty
                    , String.Verify.isInt |> and Int.Verify.isPositive
                    ]

So I ended up writing:

oneOf : error -> Nonempty (error -> input -> Result (List error) b) -> V.Validator error input b
oneOf error list subject =
    let
        step : Nonempty (error -> input -> Result (List error) b) -> Result (List error) b
        step remainingList =
            case ( Nonempty.head remainingList error subject, Nonempty.fromList (Nonempty.tail remainingList) ) of
                ( Err err, Nothing ) ->
                    Err err

                ( Err err, Just tail ) ->
                    step tail

                ( Ok value, _ ) ->
                    Ok value
    in
    step list


map : (a -> b) -> V.Validator error input a -> V.Validator error input b
map func validator =
    validator >> Result.map func


and : (error -> V.Validator error b c) -> (error -> V.Validator error a b) -> (error -> V.Validator error a c)
and second first error input =
    first error input
        |> Result.andThen (second error)

Which unfortunately requires a Nonempty list to work (I think!) as it is hard to return something appropriate if you don't have anything in the list and all the types are generic.

So to work with that and to make the types happy I've ended up with:

constraintValidator : Validator EditableCountConstraint CountConstraint
constraintValidator =
    let
        validator =
            VE.oneOf "Constraint must be empty or a positive integer" <|
                Nonempty String.Verify.Extra.empty
                    [ (String.Verify.isInt |> VE.and Int.Verify.isPositive) >> VE.map Just
                    ]
    in
    V.ok CountConstraint
        |> V.verify .minCount validator
        |> V.verify .maxCount validator

The role of 'VE.and' is to combine validators that don't have specific error messages.

It think it makes sense that oneOf should take the error message as an argument and it should be returned any everything fails. Though this loses the granularity of the errors we had above, it isn't a bad thing to always present the bigger picture if it is going to appear as an error in the UI.

Honestly, I wouldn't push to include this in the library as I suspect the custom approach is more readable. It is also more verbose which is off putting at some level. I wish the oneOf could be implemented with a normal list as that would make the API more pleasant.

Whether it is included or not, I wanted to share it. Either to help others facing similar things or to help me if someone can present a third & better way of doing it :)


Int.Verify and String.Verify.Extra are both local packages. Not published.

Int.Verify.isPositive is defined as:

isPositive : error -> Validator error Int Int
isPositive error input =
    if input > 0 then
        Ok input
    else
        Err [ error ]

And String.Verify.Extra.empty is:

empty : error -> Validator error String (Maybe a)
empty error input =
    if String.isEmpty input then
        Ok Nothing
    else
        Err [ error ]

Which I find a bit weird, but I guess makes sense.

michaeljones avatar Jan 05 '18 14:01 michaeljones