Skip to content

Latest commit

 

History

History
239 lines (193 loc) · 13 KB

README.md

File metadata and controls

239 lines (193 loc) · 13 KB

Valida

Simple, elegant, profunctorial, applicative validation for product types - batteries included!

Read the documentation on hackage.

Highlights

  • Minimal - Singular external dependency: profunctors.

    If you'd like a more lightweight version. Checkout valida-base, which offers similar functionalites without any external dependency.

  • Batteries included - Validator combinators for almost every scenario.

  • Validation without the boiler plate - Implementation of contravariance to conveniently model the common validation usecases, without extra boilerplate.

  • Profunctorial, Applicative Validator - Relating to the previous point, the provided Validator type is not only an applicative functor, but also a profunctor. This is what allows the contravariance on its input argument.

Quick Taste

import Data.List.NonEmpty (NonEmpty)

import Valida

data InputForm = InpForm
  { inpName  :: String
  , inpAge   :: Int
  , inpEmail :: Maybe String
  , inpFreeCake :: Maybe Bool
  } deriving (Show)

data ValidInput = ValidInput
  { vInpName  :: String
  , vInpAge   :: Int
  , vInpEmail :: Maybe String
  , vFreeCake :: Bool
  } deriving (Show)

data FormErr
  = InvalidNameLength
  | InvalidAge
  | NoAtCharInMail
  | NoPeriodInMail
  | InvalidEmailLength
  | YouMustGetFreeCake
  deriving (Show)

-- | Validator for each field in the input form - built using 'Validator' combinators.
inpFormValidator :: Validator (NonEmpty FormErr) InputForm ValidInput
inpFormValidator = ValidInput
    -- Name should be between 1 and 20 characters long
    <$> inpName -?> lengthWithin (1, 20) InvalidNameLength
    -- Age should be between 18 and 120
    <*> inpAge -?> valueWithin (18, 120) InvalidAge
    -- Email, if provided, should contain '@', and '.', and be atleast 5 characters long
    <*> inpEmail -?> optionally (minLengthOf 5 InvalidEmailLength
        <> mustContain '@' NoAtCharInMail
        <> mustContain '.' NoPeriodInMail)
    <*> inpFreeCake -?> failureUnless id YouMustGetFreeCake `withDefault` True

goodInput :: InputForm
goodInput = InpForm "John Doe" 42 Nothing (Just True)

badInput :: InputForm
badInput = InpForm "John Doe" 17 (Just "@") Nothing

main :: IO ()
main = do
    print (runValidator inpFormValidator goodInput)
    -- Prints- Success (ValidInput {vInpName = "John Doe", vInpAge = 42, vInpEmail = Nothing, vFreeCake = true})
    print (runValidator inpFormValidator badInput)
    -- Prints- Failure (InvalidAge :| [InvalidEmailLength])

You can also find more examples here.

Quick Start

The primary purpose of the Validator type is to validate each field in product types. To do this, you'll use verify.

verify takes 2 inputs-

  • The "selector", which essentially just takes the product type as input, and returns the specific value of the specific field to validate.
  • The Validator, which specifies the predicate the field must satisfy, the error value to yield if it doesn't satisfy said predicate, and the output upon successful validation.

Let's validate a pair for example, the first field should be an int less than 10, the second field should be a non empty string. Then, the validator would look like-

pairValidator :: Validator (NonEmpty String) (Int, String) (Int, String)
pairValidator = (,) <$> verify (failureIf (>=10) "NotLessThan10") fst <*> verify (notEmpty "EmptyString") snd

Or, if you prefer using operators - you can use -?>, which is a flipped version of verify.

pairValidator :: Validator (NonEmpty String) (Int, String) (Int, String)
pairValidator = (,)
    <$> fst -?> failureIf (>=10) "NotLessThan10"
    <*> snd -?> notEmpty "EmptyString"

You can then run the validator on your input using runValidator-

>>> runValidator pairValidator (9, "foo")
Success (9,"foo")
>>> runValidator pairValidator (10, "")
Failure ("NotLessThan10" :| ["EmptyString"])
>>> runValidator pairValidator (5, "")
Failure ("EmptyString" :| [])

This is the core concept for building the validators. You can use the primitive combinators (e.g failureIf, failureUnless) to build Validators directly from predicate functions, or you can choose one of the many derivate combinators (e.g notEmpty) to build Validators. Check out the Valida.Combinators module documentation to view all the included combinators.

Combining multiple Validators

Often, you'll find yourself in situations where you expect the input to satisfy multiple Validators (but don't need applicative composition), or situations where you expect the input to satisfy at least one of multiple Validators. This is where andAlso, and orElse come into play.

Combining multiple Validators with andAlso

andAlso is the semigroup implementation of Validator, and thus is the same as <>. Combining 2 validators with <> creates a new validator that is only satisfied when both of the given validators are satisfied.

Otherwise, the first (left most) failure value is returned - and the rest are not tried. Upon successful validation, the right-most Success value is returned. This means that if all validators succeed, only the right-most validator's success value is returned.

The following validator only succeeds if the input is odd, and not divisble by 3.

validator :: Validator (NonEmpty String) Int Int
validator = failureIf even "IsEven" `andAlso` failureIf ((==0) . flip mod 3) "IsDivisbleBy3"

(OR)

validator :: Validator (NonEmpty String) Int Int
validator = failureIf even "IsEven" <> failureIf ((==0) . flip mod 3) "IsDivisbleBy3"

Usages-

>>> runValidator validator 5
Success 5
>>> runValidator validator 4
Failure ("IsEven" :| [])
>>> runValidator validator 15
Failure ("IsDivisbleBy3" :| [])
>>> runValidator validator 6
Failure ("IsEven" :| [])

Combining multiple Validators with orElse

orElse also forms a semigroup, </> is aliased to orElse. Combining 2 validators with </> creates a new validator that is satisfied when either of the given validators are satsified. If all of them fail, the Failure values are accumulated. The left-most Success value is returned, remaining validators are not tried.

The following validator succeeds if the input is either odd, or not divisble by 3.

validator :: Validator (NonEmpty String) Int Int
validator = failureIf even "IsEven" `orElse` failureIf ((==0) . flip mod 3) "IsDivisbleBy3"

(OR)

validator :: Validator (NonEmpty String) Int Int
validator = failureIf even "IsEven" </> failureIf ((==0) . flip mod 3) "IsDivisbleBy3"

Usages-

>>> runValidator validator 5
Success 5
>>> runValidator validator 4
Success 4
>>> runValidator validator 15
Success 15
>>> runValidator validator 6
Failure ("IsEven" :| ["IsDivisbleBy3"])

Combining a foldable of Validators

You can combine a foldable of Validators using satisfyAll and satisfyAny. satisfyAll folds using andAlso/<>, while satisfyAny folds using orElse/</>.

Ignoring errors

Although, highly inadvisable and generally not useful in serious code, you may use alternative versions of Validator combinators that use () (unit) as the error type so you don't have to supply error values. For example, failureIf' does not require an error value to be supplied. In case of failure, it simply yields Failure ().

>>> runValidator (failureIf' even) 2
Failure ()

Re-assigning errors

Using the label/<?> function, you can override the errors Validators yield.

For example, to re assign the error on a Validator-

label "IsEven" (failureIf even "Foo")

(OR)

failureIf even "Foo" <?> "IsEven"

This is useful with Validators that use unit as their error type. You can create a Validator, skip assigning an error to it - and label a specific error when you need to later.

label "IsEven" (failureIf' even)

Re-labeled Validators will yield the newly assigned error value when the validator is not satisfied.

Core Idea

All usecases of applicative validation, involving validation of product types, have one noticable thing in common. A well written validator typically looks something like-

data InputForm = InpForm
  { inpName :: String
  , inpAge  :: Int
  , inpDate :: String
  } deriving (Show)

validateName :: String -> Validation [String] String
validateAge  :: Int -> Validation [String] Int
validateDate :: String -> Validation [String] String

validateForm :: InputForm -> Validation [String] InputForm
validateForm form = InputForm
  <$> validateName (inpName form)
  <*> validateAge (inpAge form)
  <*> validateDate (inpDate form)

There's a few things unideal with this. The functions validateName, validateAge, and validateDate are defined elsewhere - but all of their definitions are really similar. Yet, without handy combinators - they can't be defined in a terse way. However, the bigger problem, is how all of the validators need to be fed their specific input by selecting the field from the product type. It could look better if the validator functions could somehow just be linked to a specific field selector in an elegant way. Something like inpName -?> validateName, perhaps.

This is the perfect usecase for contravariance. A validation function, is really just a Predicate, the idiomatic example of a contravariant functor. However, it also needs to be an applicative functor to allow for the elegant composition. In fact, the type of a validation function needs to parameterize on 3 types - inp -> Validation e a

  • The input type
  • The error type
  • The output type

The output is covariant, but the input is contravariant. This is a Profunctor! With a profunctorial validator, you now have the ability to not only map the output type, but also contramap the input type.

Given a validator that makes sure an int input is even, and returns said int input as output - evenValidator, you can easily use it in the applicative validation of a (Int, Int) using lmap-

(,) <$> lmap fst evenValidator <*> lmap snd evenValidator

Contravariant input, mixed with covariant output - is the bread and butter of Valida! It allows for elegant encoding of well composable validators using only 2 simple concepts.

There's one more core idea that Valida uses though - fixV. fixV "fixes" a validator's output, to be the same as its input. fmap lets you map over the output, lmap lets you contramap over the input, fixV allows fmap to now map over the input value, on the output position. fixV also allows you to regain the input value in the output position if a validator has been fmaped on.

Comparison and Motivation

The concept of the Validation data type used in this package isn't new. It's also used in the following packages-

Valida aims to be a minimal in terms of dependencies, but batteries included in terms of API. It borrows many philosophies from Data.Validation (from validation) and Validation (from validation-selective), and aims to provide a convenient, minimal way to model the common usecases of them.

The verify function, combined with the built in Validator combinators, and the parsec-esque Validator aims to assist in easily modeling typical validation usecases without too much boilerplate, using applicative composition and contravariant input.

In essence, the validation style itself, is designed to look like forma. Though the actual types, and core concepts are significantly different.