There are many ways to validate and sanitize data in Haskell. Recently I had an interesting time with the validation in an application I wrote. In this post I will briefly show how my validation code progressed as I tried adding features to the validation.
Since it would require a lot of time to explain my application, let’s use a simple example instead. In your application you have a form that the user needs to fill out before continuing further. The user needs to enter an age and an username to continue further. There are a couple of invariants that should hold with the user input.
- Ages should be positive
- Usernames should be unique
If either of these are false, then we need to let the user know that their data is invalid. With this example we will clearly see the issues I ran into and then worked around to a good solution.
Almost everytime I start going into the unknown world of validation, I start off with the
Maybe data type. So let’s see that iteration of the code first.
import Control.Applicative type Age = Int type Username = String data Form = Form Age Username deriving (Show) validateAge :: Age -> Maybe Age validateAge age | age > 0 = Just age | otherwise = Nothing validateUsername :: Username -> Maybe Username validateUsername username | username `elem` usernames = Nothing | otherwise = Just username where --for simplicity, lets just use a list as our database of usernames usernames = ["foo", "bar", "baz"] validateForm :: Age -> Username -> Maybe Form validateForm age username = Form <$> validateAge age <*> validateUsername username
The age and username validations are simply functions that check the data according to our invariants. They either return
Nothing if the data is invalid, or give the data back wrapped in the
Just constructor. The form validation uses the
Applicative instance of
Maybe to hide some of the plumbing of checking for
Nothing in the return values of the age and username validation. If either of the two are
Nothing then the form validation also returns
This validation works, but it is a little crude so far. If we passed the result of
validateForm back to the user the only thing they know is that there is a problem with the data they gave us, and nothing more. It would be nice if the user could know which part of the form they did wrong.
This is when I moved onto using the
Either data type The
Either data type works similarly to the
Maybe type, but instead of
Nothing we can instead pass a different value down. Let’s see what that looks like.
import Control.Applicative type Age = Int type Username = String type ErrorMsg = String data Form = Form Age Username deriving (Show) validateAge :: Age -> Either ErrorMsg Age validateAge age | age > 0 = Right age | otherwise = Left "Age needs to be positive" validateUsername :: Username -> Either ErrorMsg Username validateUsername username | username `elem` usernames = Left "Username is already taken" | otherwise = Right username where --for simplicity, lets just use a list as our database of usernames usernames = ["foo", "bar", "baz"] validateForm :: Age -> Username -> Either ErrorMsg Form validateForm age username = Form <$> validateAge age <*> validateUsername username
Functionally the age and username validations are the same as before, but now we pass a
String error message when there is an error. The form validation goes through the age validation and then the username validation. If one of them gives an error, the form validation passes that error along. This works great if the user made only one error, as they can immediately fix it and move on. If there are two errors though then this implementation is a little tedious, because it will only show one error at a time. So we need a way to collect the errors and give them to the user all at once.
Extending Maybe with State
When I initially came to this point, the simplest thing I could come up with was to pass a list to the validation functions. If the function had an error, put the error in the list, and return
Nothing in a tuple with the error list. Otherwise return the result wrapped in
Just in a tuple with the unaltered error list. Essentially the validation functions had the type signature of
a -> ([String], Maybe a) One of the things that people will tell you is that if you have arguments constantly being threaded into functions then it is probably time to bring in some kind of state handling. So with this next iteration I used the type
State [String] (Maybe a) as my return value for validation.
import Control.Applicative import Control.Monad.State type Age = Int type Username = String data Form = Form Age Username deriving (Show) type FormState a = State [String] (Maybe a) pushErr :: String -> FormState a pushErr s = modify (s:) >> return Nothing validateAge :: Age -> FormState Age validateAge age | age > 0 = return . Just $ age | otherwise = pushErr "Age needs to be positive" validateUsername :: Username -> FormState Username validateUsername username | username `elem` usernames = pushErr "Username is already taken" | otherwise = return . Just $ username where --for simplicity, lets just use a list as our database of usernames usernames = ["foo", "bar", "baz"] validateForm :: Age -> Username -> FormState Form validateForm age username = do age' <- validateAge age username' <- validateUsername username return (Form <$> age' <*> username')
So now we have state being threaded into the validation functions. If either of the validations have errors, they are pushed into the error stack and then return
Nothing. In the form validation, we run both of the stateful validation computations, and then do the same thing with their result as we did in our
Maybe data type iteration. So now when we do the form validation we see both of the errors at once if they both happen to be invalid.
So here is the final result of my work so far. Although this covers quite a bit of my use case, I can see a couple areas that could be made nicer still. Right now the code doesn’t do much for us when it comes to checking multiple invariants on the same piece of data. It’d be nice if we could hide that plumbing as well, but that will be for a later post.comments powered by Disqus