This is (was) a learning project I used to understand Haskell type classes by implementing instances of them on a real and potentially useful type. The library is fully functional even though it was created as an exercise!
Haskell validation library with two explicit modes: accumulating errors (ValidatorA) and sequential pipelines (ValidatorM).
type Validator e i a = i -> Either (NonEmpty e) ae — error type, i — input type, a — output type. Parameterizing input and output enables typed transformation pipelines where each step narrows the type.
Two newtypes over the same underlying function, zero-cost to convert between them:
newtype ValidatorA e i a = ValidatorA (Validator e i a) -- Applicative, accumulates errors
newtype ValidatorM e i a = ValidatorM (Validator e i a) -- Monad + Category, short-circuitsApplicative runs all validators on the same input and merges failures via Semigroup. Use for independent checks that should all be reported at once.
-- Semigroup: parallel checks on same input, accumulate errors
passwordStrength :: ValidatorA String String String
passwordStrength = ValidatorA (minLength 8) <> hasUppercase <> hasDigit <> hasSpecialChar
validateA passwordStrength "weak"
-- Left ("Expected string with at least n length" :| ["Must contain uppercase letter", "Must contain digit", "Must contain special character"])
-- Applicative: validate a record, collect all field errors
data RegistrationInput = RegistrationInput { regName :: String, regEmail :: String, regAge :: String }
data User = User String Email Int
registrationForm :: ValidatorA String RegistrationInput User
registrationForm =
User
<$> field regName (ValidatorA isNotEmpty <> ValidatorA (minLength 2))
<*> field regEmail (ValidatorA isEmail)
<*> field regAge (toA ageRange)
validateA registrationForm (RegistrationInput "" "notanemail" "999")
-- Left ("Expected non-empty string" :| ["Must be a valid email", "Expected int to be at most the specified value"])Category chains validators where the output of one becomes the input of the next. The type at each step proves what has been validated. Short-circuits on first failure.
emailPipeline :: ValidatorM String String GmailEmail
emailPipeline =
ValidatorM isTrimmed
>>> ValidatorM isNotEmpty
>>> ValidatorM isEmail -- String → Email
>>> ValidatorM isGmailEmail -- Email → GmailEmail
validateM emailPipeline "user@gmail.com" -- Right (GmailEmail "user@gmail.com")
validateM emailPipeline " notanemail " -- Left ("Expected trimmed string" :| [])Monad instance enables dynamic validation where the result of one step drives the next:
scoreGrade :: ValidatorM String String String
scoreGrade = do
score <- ValidatorM isInt
if score >= 90 then pure "A"
else if score >= 80 then pure "B"
else if score >= 70 then pure "C"
else ValidatorM (\_ -> err "Below passing grade (70)")toA and toM are zero-cost — the underlying function is identical:
toA :: ValidatorM e i a -> ValidatorA e i a
toM :: ValidatorA e i a -> ValidatorM e i aBuild a sequential pipeline in ValidatorM, convert to ValidatorA for use in an accumulating form:
ageRange :: ValidatorM String String Int
ageRange = ValidatorM isInt >>> ValidatorM (min 0) >>> ValidatorM (max 150)
field regAge (toA ageRange) -- used inside a ValidatorA Applicative formDefine validators as plain Validator e i a functions, lift at use site:
-- String → String
isNotEmpty :: Validator String String String
minLength :: Int -> Validator String String String
maxLength :: Int -> Validator String String String
isTrimmed :: Validator String String String
-- String → Int
isInt :: Validator String String Int
-- Int → Int
min :: Int -> Validator String Int Int
max :: Int -> Validator String Int Int
-- utility
err :: e -> Either (NonEmpty e) aLift into either mode:
ValidatorA isNotEmpty -- accumulating
ValidatorM isNotEmpty -- sequentialExtract a sub-field from a record and run a validator on it:
field :: (i -> f) -> ValidatorA e f a -> ValidatorA e i aEnables clean Applicative syntax over records without boilerplate.
| Situation | Use |
|---|---|
| Independent checks, report all errors | ValidatorA + <> / <*> |
| Pipeline where output feeds next step | ValidatorM + >>> |
| Result of one check drives next check | ValidatorM + do / >>= |
| Mix both in one form | build pipeline in ValidatorM, toA to embed |
Clone and run the playground. No local Haskell toolchain needed — open in a dev container:
git clone https://github.com/erikputar/zoskell
cd zoskell
cabal run playground