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.
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
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
RowList parameter here comes from the rules passed in, which are technically from
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