Yesod Forms, Newtypes, and Smart Constructors
Say we’re writing a web application, and we’re modelling a login form.
If the types in your system are primitive, you don’t need to do much to parse them from values outside your system boundary, i.e., those submitted by a user through a web form.
It’s easy enough to use a textField
for each field in our login form.
-- Assume this type synonym exists for all examples in this article
type Form x = Html -> MForm (HandlerFor App) (FormResult x, Widget)
-- A login form with a single field
data LoginForm = LoginForm
{ loginFormEmail :: Text
}
loginForm :: Form LoginForm
loginForm extra = do
email <- mreq textField "" Nothing
pure (LoginForm <$> fst email, $(widgetFile "login"))
No surprises here.
With Newtypes
What if our types aren’t exactly Text
values, but are some kind of
equivalent type? For example, what if we’re representing our email value with a
newtype which wraps the underlying text value?
In Yesod, a Field
cannot be a functor so it’s not obvious how to reuse a
textField
and make it produce an Email
value instead.
Fortunately, Yesod’s form library provides convertField
to handle
this case. You apply this function to a couple of functions for converting to
and from your newtype, and a form field you wish to wrap.
newtype Email = Email { unEmail :: Text }
data LoginForm = LoginForm
{ loginFormEmail :: Email
}
loginForm :: Form LoginForm
loginForm extra = do
email <- mreq (convertField Email unEmail textField) "" Nothing
pure (LoginForm <$> fst email, $(widgetFile "login"))
With Smart Constructors
Newtype wrappers are better than working directly with primitive types, but by themselves they don’t provide a great deal of type safety because the wrapped data isn’t any more constrained than when it’s unwrapped.
To solve that, we would reach for a smart constructor.
-- Explicitly exclude the value constructor
module Email (Email, unEmail, email) where
-- The newtype wrapper without a record field
newtype Email = Email Text
-- Unwrap the newtype
unEmail :: Email -> Text
unEmail (Email email) = email
-- The smart constructor
email :: Text -> Maybe Email
email t
| "@" `isInfixOf` t = Just (Email t)
| otherwise = Nothing
What if we’re using the smart constructor pattern and our newtypes can’t be
naïvely constructed? We can’t use convertField
because the types won’t line
up.
Again, Yesod conveniently provides checkMMap
for transforming some
existing field into one that both performs validation and converts the
datatype. This way we can use our smart constructor in the field directly
instead of having to define validation rules in two places.
This function wants to ultimately produce an Either msg b
but our smart
constructor only produces a Maybe b
. We can use the note
function to
promote it and provide a friendly error message.
module Main where
import Email
data LoginForm = LoginForm
{ loginFormEmail :: Email
}
loginForm :: Form LoginForm
loginForm extra = do
email <-
let msg = asText "Invalid email"
checkEmail = pure . note msg . email
in mreq (checkMMap checkEmail unEmail textField) "" Nothing
pure (LoginForm <$> fst email, $(widgetFile "login"))
Since checkMMap
runs in the Handler
monad, you can also run IO actions or
database transactions as part of the validation step. For example, you could
query the database and check that the email address you’re trying to log in
with actually exists.
If you want a little more assurance, it might be worth writing a property-based test which asserts that your functions to convert to and from your newtype successfully roundtrip.
Yesod’s form library is actually pretty powerful and satisying. It could perhaps do with more examples of what good looks like, and hopefully this short article helps. I’ve found that my code is generally neater when I’m able to manage parsing/validation together at the web form level.