Suppose you would like to perform multiple validations on your data before you write it to the database or some other store. Some likely constraints are as follows:
- You would like all of the validations to pass before any data is written
- If there are multiple validation errors, you would like to receive all of them
- It should be possible to easily compose such actions out of smaller parts
In a relational database, there is a simple solution which satisfies the constraints: open a transaction, perform all validations and writes in the transaction, and commit. We can interleave writes with validations, because any failure to validate will roll back all writes.
While the transactional approach is probably the right way in most cases, there can be cases where we might be happier with a less robust design, and willing to trade off some correctness for flexibility. For example, we might not even have access to transactions (perhaps we are making API calls, instead of writing directly to the database) or we might want to perform validations in the application code instead of in the database.
I’m going to explain a simple technique we’ve been using at Lumi in these cases.
An initial API
A computation which does some validation and then writes to the database (or performs some other IO) could be described by the following types:
prepare :: Validation err prepared
commit :: prepared -> IO result
Here I’m using the Validation
type from the validation package, so that we can capture all validation errors at once.
We can package this data up as a data type:
data Action err prepared result = Action
{ prepare :: Validation err prepared
, commit :: prepared -> IO result
}
Reducing the number of type variables
The type variable prepared
is actually unimportant - any function that interprets an Action
could reasonably be expected to ignore the type of the intermediate prepared result - so let’s existentially quantify over it:
{-# LANGUAGE GADTs #-}
data Action err result where
Action :: Validation err prepared
-> (prepared -> IO result)
-> Action err result
This type is isomorphic to the following type, where we have removed prepared
entirely, by fmap
ping the second function over the result of the first:
data Action err result = Action
{ runAction :: Validation err (IO result)
}
At this point, we don’t need to define our own type at all, since we can reuse the Compose
type from base:
type Action err = Compose (Validation err) IO
This presentation is convenient, because we can use the Hackage documentation to discover all sorts of helpful type class instances. In particular, the composition of two Applicative
functors is also Applicative
:
(Applicative f, Applicative g) => Applicative (Compose f g)
If the outermost constructor is Alternative
, then so is the composition:
(Alternative f, Applicative g) => Alternative (Compose f g)
Since Validation err
and IO
are both Applicative
, we get an instance:
Semigroup err => Applicative (Action err)
We don’t quite get Alternative
, because Validation err
is not an Alternative
, but we do get Alt
from semigroupoids:
Alt (Action err)
These two instances give us two simple and convenient ways to compose actions together. We can combine two actions using <*>
(or even applicative do
notation) and require both validations to succeed before either IO action is initiated. Alternatively (pun intended), we can have one action fall back to a second action in the case of a validation failure, by using <!>
.
Example
Suppose we have a simple Person
data type:
data Person = Person
{ firstName :: Text
, lastName :: Text
}
and a way to write a Person
to our data store:
writePerson :: Person -> IO Person
We can build an Action
which performs a validated write:
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE RecordWildcards #-}
writePersonAction :: Person -> Action Errors Person
writePersonAction p = Compose $ do
firstName <- validateFirstName (firstName p)
lastName <- validateLastName (lastName p)
pure (writePerson Person{..})
Here, we’re using the Applicative
instance for Validation
, but we can also use Applicative
to combine two Actions
:
multipleWrites :: Person -> Person -> Action Errors (Person, Person)
multipleWrites p1 p2 =
(,) <$> writePersonAction p1
<*> writePersonAction p2
We can even use Traversable
to perform bulk database updates:
bulkWrite :: [Person] -> Action Errors [Person]
bulkWrite = traverse multipleWrites
In this final example, no writes will be performed if any Person
structure has a validation error!
Related posts
RSS · Github