Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

zoskell

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).

Core type

type Validator e i a = i -> Either (NonEmpty e) a

e — 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-circuits

ValidatorA — accumulate all errors

Applicative 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"])

ValidatorM — sequential pipeline

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)")

Converting between modes

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 a

Build 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 form

Primitives

Define 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) a

Lift into either mode:

ValidatorA isNotEmpty  -- accumulating
ValidatorM isNotEmpty  -- sequential

field combinator

Extract a sub-field from a record and run a validator on it:

field :: (i -> f) -> ValidatorA e f a -> ValidatorA e i a

Enables clean Applicative syntax over records without boilerplate.

When to use which

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

Usage

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

About

Haskell validation library with two explicit modes: accumulating errors and sequential pipelines. A learning project.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages