A while ago, I talked about using row types to store validations performed on a string, but I was left feeling like the solution I came up with then wasn't quite there yet.
Recently, I gave a talk about RowLists at Small FP Conf and was determined to use RowLists to improve on my previous encodings, which gave rise to Home-Run-Ball.
This library allows you to specify rules to be applied to validate a value against a set of rules and get back a validation, where on success you get back the value with all the validated rules as a Const, and on failure you get back a list of variants, where the tags used are the labels from the rows passed in, with the values being the reflected strings. Let's get to the details.
If I want to work with validated values, there's a couple things I already know:
- I have names that I use for what rules I'm applying.
- I know I don't want to work with a closed set of validation rules: I should be able to use a bunch of rules from a library and also make my own. Using a sum type for this is no good for me.
- When I get a list of errors back, the possible error names are statically known -- anything that doesn't let me handle that explicitly isn't fun.
- When I get my validated value back, I want to make functions that will then require a subset of the rules that have been applied.
The usage of my library then ends up being the following:
rules :: RProxy (beginsApple :: BeginsWith "Apple")
rules = RProxy
Here we make a row proxy with labels for identifying the rule, with a rule data BeginsWith :: Symbol -> Type
.
onlyOnApples ::
ValidatedValue (beginsApple :: BeginsWith "Apple") String
-> String
onlyOnApples _ = "U R COOL"
This definition uses the alias type ValidatedValue (rules :: # Type) a = (...)
to require a validated value with the beginsApple :: BeginsWith "Apple"
rule from earlier.
-- type VS errors rules a = (...)
validation :: VS (beginsApple :: String) (beginsApple :: BeginsWith "Apple") String
validation = checkRules rules "AppleSDdf"
validation' :: V (NonEmptyList (Variant (beginsApple :: String))) String
validation' = onlyOnApples <$> validation
Here we use the function checkRules
to check an input to our rules and produce a validation V
with our list of variants of the labels in the rules row for errors and our validated value for the success. We can then use onlyOnApples
from above accordingly.
There are two parts involved here: rule validation and checking of all the rules.
Earlier, I showed the definition of BeginsWith
, but rules can be defined simply as data Capitalized
. The validation routines are defined by using the ValidateRule
class:
class ValidateRule rule a where
validateRuleImpl :: Proxy rule -> a -> Boolean
By taking the proxy in, the type class instance gets solved for ValidateRule (BeginsWith prefix) String
as defined:
instance validateRuleBeginsWith ::
( IsSymbol prefix
) => ValidateRule (BeginsWith prefix) String where
validateRuleImpl _ str =
isJust $ stripPrefix (Pattern $ reflectSymbol (SProxy :: SProxy prefix)) str
In this case, the prefix gets reflected to be used for attempting to strip the prefix from the input.
This class drives the application of the rules.
class CheckRules (rl :: RowList) (errors :: # Type) (rules :: # Type) a
| rl -> errors rules where
checkRulesImpl :: RLProxy rl -> a -> V (NonEmptyList (Variant errors)) Unit
The RowList
parameter here comes from the rules passed in, which are technically from rules
, but rules
itself can't be used to do instance matching. The errors are produced from the labels in rules reflected into the variant. The final parameter a
is used to match the type of the input to the ValidateRule
instance being called.
The method then ends up being the row list proxy being passed in with the value to produce a validation V
with the variant errors, but with Unit
, as we don't need to return the value from the instance (and we'll see below why we don't want to).
Let's look at the base case:
instance checkRulesNil :: CheckRules Nil errors rules a where
checkRulesImpl _ str = pure unit
This instance returns the unit as-is from the method, as no rules can be validated if we have no more rules to apply.
Let's look at the Cons
instance, first looking only at the constraints:
instance checkRulesCons ::
( IsSymbol name
, CheckRules tail errors rules a
, RowCons name String trash errors
, ValidateRule ty a
) => CheckRules (Cons name ty tail) errors rules a where
checkRulesImpl _ str = (...)
So in this instance we declare that the name is a symbol so that we can reflect it in case we have an error. We then check that the rules in the rest of the row list apply to the value. The RowCons
constraint adds the label with a String value to the errors row (while ignoring what we do with the "subrow"). Finally, the actual rule applying constraint is provided by using ValidateRule
with the rule type inside our row list along with our validating value's type.
With these constraints, we define the method as follows:
checkRulesImpl _ str = curr <> rest
where
curr
| validateRuleImpl (Proxy :: Proxy ty) str = pure unit
| otherwise
, namep <- SProxy :: SProxy name
, name <- reflectSymbol namep =
invalid <<< pure $ inj namep name
rest = checkRulesImpl (RLProxy :: RLProxy tail) str
By using append (<>
), I'm able to append together the validation errors. If I were to return the validating value inside the validation, I would end up with an unusable mess, so I opted to use Unit
here since we don't need the result at this point.
To then provide a usable API, I define checkRules
as so:
checkRules :: forall a row errors rl
. RowToList row rl
=> CheckRules rl errors row a
=> RProxy row
-> a
-> VS errors row a
checkRules _ str = const (Const str) <$> checkRulesImpl (RLProxy :: RLProxy rl) str
From a row proxy of the rules to be applied, we can use RowToList
to convert that row to the row list we will use, and then further use the CheckRules
constraint to check the row list and row rules with the input type to produce the error row that is used for the output type. It's like Type Tetris, and that's about it!
Hopefully this has shown that writing a validation library doesn't have to be too horrible, and that row types let us represent a lot more fun stuff.
Extra thanks to Hardy (@st58) for all the help in improving the library and providing a really cool demo here and Christoph (@kritzcreek) for the help with walking through the initial version of this library.
If you have any questions about this library, RowList stuff, or anything, please ask me anything on Twitter @jusrin00 or through /r/purescript. Thanks!
- This repo: https://github.com/justinwoo/purescript-home-run-ball
- Hardy's demo: https://joneshf.github.io/purescript-home-run-ball-demo/, https://github.com/joneshf/purescript-home-run-ball-demo
Checking an Int with your own defined rules is quite easy with this library:
data Even
instance validateRuleEven :: ValidateRule Even Int where
validateRuleImpl _ n = mod n 2 == 0
intRules = RProxy :: RProxy (isEven :: Even)
main = -- ...
it "works with Int too!" do
let
checkedNumber = checkRules intRules 4
isValid checkedNumber `shouldEqual` true