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.