--- name: fp-ts-validation description: Validation patterns using fp-ts with error accumulation, form validation, and API input validation version: 1.0.0 author: kadu tags: - fp-ts - validation - either - error-accumulation - form-validation - api-validation - io-ts - zod - typescript - functional-programming --- # fp-ts Validation Patterns This skill covers validation patterns using fp-ts, focusing on error accumulation, form validation, and API input validation. ## Core Concepts ### Either for Validation `Either` represents a computation that can fail with error `E` or succeed with value `A`. ```typescript import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' // Basic validation function signature type Validation = E.Either // Simple validation returning Either const validateNonEmpty = (field: string) => (value: string): E.Either => value.trim().length > 0 ? E.right(value.trim()) : E.left(`${field} cannot be empty`) const validateMinLength = (field: string, min: number) => (value: string): E.Either => value.length >= min ? E.right(value) : E.left(`${field} must be at least ${min} characters`) const validateEmail = (email: string): E.Either => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? E.right(email) : E.left('Invalid email format') ``` ## Fail-Fast vs Error Accumulation ### Fail-Fast Pattern (chain/flatMap) Stops at the first error encountered. Use when subsequent validations depend on previous ones. ```typescript import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function' // Fail-fast: stops at first error const validateUserFailFast = (input: { name: string; email: string }) => pipe( validateNonEmpty('name')(input.name), E.chain(name => pipe( validateEmail(input.email), E.map(email => ({ name, email })) )) ) // Usage validateUserFailFast({ name: '', email: 'invalid' }) // Result: Left('name cannot be empty') - email error not reported ``` ### Error Accumulation Pattern Collects ALL errors using `Applicative` with a `Semigroup` for combining errors. ```typescript import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Apply' import * as NEA from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function' // Define error type as NonEmptyArray for accumulation type ValidationErrors = NEA.NonEmptyArray // Lift validation to use NonEmptyArray for errors const validateNonEmptyV = (field: string) => (value: string): E.Either => value.trim().length > 0 ? E.right(value.trim()) : E.left(NEA.of(`${field} cannot be empty`)) const validateEmailV = (email: string): E.Either => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? E.right(email) : E.left(NEA.of('Invalid email format')) const validateMinLengthV = (field: string, min: number) => (value: string): E.Either => value.length >= min ? E.right(value) : E.left(NEA.of(`${field} must be at least ${min} characters`)) // Get applicative that accumulates errors const applicativeValidation = E.getApplicativeValidation(NEA.getSemigroup()) ``` ## Using sequenceT and sequenceS ### sequenceT - Tuple-based Combining Combines validations into a tuple, accumulating all errors. ```typescript import { sequenceT } from 'fp-ts/Apply' // Combine multiple validations with error accumulation const validateUserWithSequenceT = (input: { name: string; email: string; age: string }) => { const validateAge = (age: string): E.Either => { const parsed = parseInt(age, 10) return isNaN(parsed) || parsed < 0 || parsed > 150 ? E.left(NEA.of('Age must be a valid number between 0 and 150')) : E.right(parsed) } return pipe( sequenceT(applicativeValidation)( validateNonEmptyV('name')(input.name), validateEmailV(input.email), validateAge(input.age) ), E.map(([name, email, age]) => ({ name, email, age })) ) } // Usage - collects ALL errors validateUserWithSequenceT({ name: '', email: 'invalid', age: '-5' }) // Result: Left(['name cannot be empty', 'Invalid email format', 'Age must be a valid number between 0 and 150']) ``` ### sequenceS - Struct-based Combining Combines validations into a struct/object, preserving field names. ```typescript import { sequenceS } from 'fp-ts/Apply' interface UserInput { name: string email: string password: string } interface ValidatedUser { name: string email: string password: string } const validateUserWithSequenceS = (input: UserInput): E.Either => sequenceS(applicativeValidation)({ name: validateNonEmptyV('name')(input.name), email: validateEmailV(input.email), password: pipe( validateNonEmptyV('password')(input.password), E.chain(p => validateMinLengthV('password', 8)(p)) ) }) // Usage validateUserWithSequenceS({ name: 'John', email: 'invalid', password: '123' }) // Result: Left(['Invalid email format', 'password must be at least 8 characters']) ``` ## Form Validation with Error Accumulation ### Complete Form Validation Example ```typescript import * as E from 'fp-ts/Either' import * as A from 'fp-ts/Apply' import * as NEA from 'fp-ts/NonEmptyArray' import { sequenceS } from 'fp-ts/Apply' import { pipe } from 'fp-ts/function' // Custom error type with field information interface FieldError { field: string message: string } type FormErrors = NEA.NonEmptyArray const fieldError = (field: string, message: string): FormErrors => NEA.of({ field, message }) const formApplicative = E.getApplicativeValidation(NEA.getSemigroup()) // Reusable validators const required = (field: string) => (value: string): E.Either => value.trim().length > 0 ? E.right(value.trim()) : E.left(fieldError(field, 'This field is required')) const minLength = (field: string, min: number) => (value: string): E.Either => value.length >= min ? E.right(value) : E.left(fieldError(field, `Must be at least ${min} characters`)) const maxLength = (field: string, max: number) => (value: string): E.Either => value.length <= max ? E.right(value) : E.left(fieldError(field, `Must be at most ${max} characters`)) const pattern = (field: string, regex: RegExp, message: string) => (value: string): E.Either => regex.test(value) ? E.right(value) : E.left(fieldError(field, message)) const email = (field: string) => pattern(field, /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format') const numeric = (field: string) => (value: string): E.Either => { const num = parseFloat(value) return isNaN(num) ? E.left(fieldError(field, 'Must be a valid number')) : E.right(num) } const inRange = (field: string, min: number, max: number) => (value: number): E.Either => value >= min && value <= max ? E.right(value) : E.left(fieldError(field, `Must be between ${min} and ${max}`)) // Compose validators for a single field (fail-fast within field) const composeValidators = ( v1: (a: A) => E.Either, ...validators: Array<(b: B) => E.Either> ) => (value: A): E.Either => validators.reduce( (acc, validator) => pipe(acc, E.chain(validator)), v1(value) ) // Registration form validation interface RegistrationInput { username: string email: string password: string confirmPassword: string age: string } interface ValidatedRegistration { username: string email: string password: string age: number } const validateRegistration = (input: RegistrationInput): E.Either => { const validateUsername = composeValidators( required('username'), minLength('username', 3), maxLength('username', 20), pattern('username', /^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed') ) const validateEmail = composeValidators( required('email'), email('email') ) const validatePassword = composeValidators( required('password'), minLength('password', 8), pattern('password', /[A-Z]/, 'Must contain at least one uppercase letter'), pattern('password', /[a-z]/, 'Must contain at least one lowercase letter'), pattern('password', /[0-9]/, 'Must contain at least one number') ) const validateAge = composeValidators( required('age'), numeric('age'), inRange('age', 13, 120) ) // Cross-field validation const validatePasswordMatch = (): E.Either => input.password === input.confirmPassword ? E.right(undefined) : E.left(fieldError('confirmPassword', 'Passwords do not match')) return pipe( sequenceS(formApplicative)({ username: validateUsername(input.username), email: validateEmail(input.email), password: validatePassword(input.password), age: validateAge(input.age), _passwordMatch: validatePasswordMatch() }), E.map(({ username, email, password, age }) => ({ username, email, password, age })) ) } // Usage const result = validateRegistration({ username: 'ab', email: 'invalid', password: 'weak', confirmPassword: 'different', age: '10' }) // Result: Left([ // { field: 'username', message: 'Must be at least 3 characters' }, // { field: 'email', message: 'Invalid email format' }, // { field: 'password', message: 'Must be at least 8 characters' }, // { field: 'age', message: 'Must be between 13 and 120' }, // { field: 'confirmPassword', message: 'Passwords do not match' } // ]) // Helper to convert errors to a field-indexed object const errorsToRecord = (errors: FormErrors): Record => errors.reduce((acc, err) => ({ ...acc, [err.field]: [...(acc[err.field] || []), err.message] }), {} as Record) // For React forms const getFieldErrors = (result: E.Either) => (field: string): string[] => pipe( result, E.fold( errors => errors.filter(e => e.field === field).map(e => e.message), () => [] ) ) ``` ## API Input Validation ### Request Body Validation ```typescript import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { sequenceS } from 'fp-ts/Apply' import { pipe } from 'fp-ts/function' // API error types interface ApiValidationError { code: string field: string message: string } type ApiErrors = NEA.NonEmptyArray const apiError = (code: string, field: string, message: string): ApiErrors => NEA.of({ code, field, message }) const apiApplicative = E.getApplicativeValidation(NEA.getSemigroup()) // Common API validators const requiredField = (field: string) => (value: T | null | undefined): E.Either => value != null ? E.right(value) : E.left(apiError('REQUIRED', field, `${field} is required`)) const stringField = (field: string) => (value: unknown): E.Either => typeof value === 'string' ? E.right(value) : E.left(apiError('INVALID_TYPE', field, `${field} must be a string`)) const numberField = (field: string) => (value: unknown): E.Either => typeof value === 'number' && !isNaN(value) ? E.right(value) : E.left(apiError('INVALID_TYPE', field, `${field} must be a number`)) const arrayField = (field: string) => (value: unknown): E.Either => Array.isArray(value) ? E.right(value as T[]) : E.left(apiError('INVALID_TYPE', field, `${field} must be an array`)) const uuidField = (field: string) => (value: string): E.Either => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) ? E.right(value) : E.left(apiError('INVALID_FORMAT', field, `${field} must be a valid UUID`)) const enumField = (field: string, allowed: readonly T[]) => (value: string): E.Either => allowed.includes(value as T) ? E.right(value as T) : E.left(apiError('INVALID_VALUE', field, `${field} must be one of: ${allowed.join(', ')}`)) // Create Order API validation interface CreateOrderRequest { customerId: string items: Array<{ productId: string quantity: number }> shippingAddress: { street: string city: string postalCode: string country: string } paymentMethod: string } interface ValidatedOrder { customerId: string items: Array<{ productId: string; quantity: number }> shippingAddress: { street: string city: string postalCode: string country: string } paymentMethod: 'credit_card' | 'paypal' | 'bank_transfer' } const validateOrderItem = (item: unknown, index: number): E.Either => { if (typeof item !== 'object' || item === null) { return E.left(apiError('INVALID_TYPE', `items[${index}]`, 'Item must be an object')) } const obj = item as Record return sequenceS(apiApplicative)({ productId: pipe( requiredField(`items[${index}].productId`)(obj.productId), E.chain(stringField(`items[${index}].productId`)), E.chain(uuidField(`items[${index}].productId`)) ), quantity: pipe( requiredField(`items[${index}].quantity`)(obj.quantity), E.chain(numberField(`items[${index}].quantity`)), E.chain(n => n > 0 ? E.right(n) : E.left(apiError('INVALID_VALUE', `items[${index}].quantity`, 'Quantity must be positive')) ) ) }) } const validateAddress = (address: unknown): E.Either => { if (typeof address !== 'object' || address === null) { return E.left(apiError('INVALID_TYPE', 'shippingAddress', 'Shipping address must be an object')) } const obj = address as Record return sequenceS(apiApplicative)({ street: pipe( requiredField('shippingAddress.street')(obj.street), E.chain(stringField('shippingAddress.street')) ), city: pipe( requiredField('shippingAddress.city')(obj.city), E.chain(stringField('shippingAddress.city')) ), postalCode: pipe( requiredField('shippingAddress.postalCode')(obj.postalCode), E.chain(stringField('shippingAddress.postalCode')) ), country: pipe( requiredField('shippingAddress.country')(obj.country), E.chain(stringField('shippingAddress.country')) ) }) } const validateCreateOrder = (body: unknown): E.Either => { if (typeof body !== 'object' || body === null) { return E.left(apiError('INVALID_TYPE', 'body', 'Request body must be an object')) } const obj = body as Record // Validate items array with accumulation const validateItems = (items: unknown[]): E.Either> => { if (items.length === 0) { return E.left(apiError('INVALID_VALUE', 'items', 'Order must contain at least one item')) } const validatedItems = items.map((item, index) => validateOrderItem(item, index)) // Accumulate all item errors return validatedItems.reduce( (acc, itemResult) => pipe( sequenceS(apiApplicative)({ acc, item: itemResult }), E.map(({ acc, item }) => [...acc, item]) ), E.right([]) as E.Either> ) } return pipe( sequenceS(apiApplicative)({ customerId: pipe( requiredField('customerId')(obj.customerId), E.chain(stringField('customerId')), E.chain(uuidField('customerId')) ), items: pipe( requiredField('items')(obj.items), E.chain(arrayField('items')), E.chain(validateItems) ), shippingAddress: pipe( requiredField('shippingAddress')(obj.shippingAddress), E.chain(validateAddress) ), paymentMethod: pipe( requiredField('paymentMethod')(obj.paymentMethod), E.chain(stringField('paymentMethod')), E.chain(enumField('paymentMethod', ['credit_card', 'paypal', 'bank_transfer'] as const)) ) }) ) } // Express middleware example const validateRequest = (validator: (body: unknown) => E.Either) => (req: { body: unknown }, res: { status: (n: number) => { json: (body: unknown) => void } }, next: () => void) => { pipe( validator(req.body), E.fold( errors => res.status(400).json({ error: 'Validation failed', details: errors }), validated => { (req as any).validatedBody = validated next() } ) ) } ``` ## Custom Error Types ### Branded Error Types ```typescript import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' // Domain-specific error types type ValidationErrorCode = | 'REQUIRED' | 'MIN_LENGTH' | 'MAX_LENGTH' | 'PATTERN' | 'RANGE' | 'INVALID_TYPE' | 'BUSINESS_RULE' interface DomainValidationError { readonly _tag: 'ValidationError' readonly code: ValidationErrorCode readonly field: string readonly message: string readonly metadata?: Record } const validationError = ( code: ValidationErrorCode, field: string, message: string, metadata?: Record ): DomainValidationError => ({ _tag: 'ValidationError', code, field, message, metadata }) type DomainErrors = NEA.NonEmptyArray // Helper to create single error const singleError = ( code: ValidationErrorCode, field: string, message: string, metadata?: Record ): DomainErrors => NEA.of(validationError(code, field, message, metadata)) // Validators with rich error metadata const minLengthWithMeta = (field: string, min: number) => (value: string): E.Either => value.length >= min ? E.right(value) : E.left(singleError('MIN_LENGTH', field, `Must be at least ${min} characters`, { actual: value.length, required: min })) ``` ## Integration with io-ts ### Basic io-ts Integration ```typescript import * as t from 'io-ts' import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function' import { PathReporter } from 'io-ts/PathReporter' // Define codec with io-ts const UserCodec = t.type({ name: t.string, email: t.string, age: t.number, role: t.union([t.literal('admin'), t.literal('user'), t.literal('guest')]) }) type User = t.TypeOf // Convert io-ts errors to our error format interface IoTsValidationError { field: string message: string } const fromIoTsErrors = (errors: t.Errors): NEA.NonEmptyArray => { const mapped = errors.map(error => ({ field: error.context.map(c => c.key).filter(k => k !== '').join('.') || 'root', message: `Invalid value ${JSON.stringify(error.value)} supplied` })) return mapped as NEA.NonEmptyArray } // Decode with accumulated errors const decodeUser = (input: unknown): E.Either, User> => pipe( UserCodec.decode(input), E.mapLeft(fromIoTsErrors) ) // Combine io-ts decoding with custom validation const validateAndDecodeUser = (input: unknown): E.Either, User> => pipe( decodeUser(input), E.chain(user => { const errors: IoTsValidationError[] = [] if (user.name.length < 2) { errors.push({ field: 'name', message: 'Name must be at least 2 characters' }) } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) { errors.push({ field: 'email', message: 'Invalid email format' }) } if (user.age < 0 || user.age > 150) { errors.push({ field: 'age', message: 'Age must be between 0 and 150' }) } return errors.length > 0 ? E.left(errors as NEA.NonEmptyArray) : E.right(user) }) ) ``` ### Custom io-ts Codecs with Validation ```typescript import * as t from 'io-ts' import * as E from 'fp-ts/Either' // Custom branded types with validation interface EmailBrand { readonly Email: unique symbol } type Email = t.Branded const Email = t.brand( t.string, (s): s is Email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s), 'Email' ) interface NonEmptyStringBrand { readonly NonEmptyString: unique symbol } type NonEmptyString = t.Branded const NonEmptyString = t.brand( t.string, (s): s is NonEmptyString => s.trim().length > 0, 'NonEmptyString' ) interface PositiveIntBrand { readonly PositiveInt: unique symbol } type PositiveInt = t.Branded const PositiveInt = t.brand( t.number, (n): n is PositiveInt => Number.isInteger(n) && n > 0, 'PositiveInt' ) // Codec using branded types const ValidatedUserCodec = t.type({ name: NonEmptyString, email: Email, age: PositiveInt }) type ValidatedUser = t.TypeOf ``` ## Integration with Zod ### Zod with fp-ts Either ```typescript import { z } from 'zod' import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { pipe } from 'fp-ts/function' // Define Zod schema const UserSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email format'), age: z.number().int().min(0).max(150), role: z.enum(['admin', 'user', 'guest']) }) type ZodUser = z.infer // Convert Zod errors to our format interface ZodValidationError { field: string message: string } const fromZodError = (error: z.ZodError): NEA.NonEmptyArray => { const errors = error.errors.map(err => ({ field: err.path.join('.') || 'root', message: err.message })) return errors as NEA.NonEmptyArray } // Parse with Either const parseUser = (input: unknown): E.Either, ZodUser> => { const result = UserSchema.safeParse(input) return result.success ? E.right(result.data) : E.left(fromZodError(result.error)) } // Combine Zod with additional fp-ts validation const validateUser = (input: unknown): E.Either, ZodUser> => pipe( parseUser(input), E.chain(user => { // Additional business logic validation const errors: ZodValidationError[] = [] if (user.role === 'admin' && user.age < 18) { errors.push({ field: 'role', message: 'Admins must be at least 18 years old' }) } return errors.length > 0 ? E.left(errors as NEA.NonEmptyArray) : E.right(user) }) ) ``` ### Zod Refinements with Error Accumulation ```typescript import { z } from 'zod' import * as E from 'fp-ts/Either' import * as NEA from 'fp-ts/NonEmptyArray' import { sequenceS } from 'fp-ts/Apply' // Separate schemas for independent validation const nameSchema = z.string().min(2, 'Name must be at least 2 characters') const emailSchema = z.string().email('Invalid email format') const ageSchema = z.number().int().min(0, 'Age must be non-negative').max(150, 'Age must be at most 150') interface ValidationError { field: string message: string } type ValidationErrors = NEA.NonEmptyArray const parseWithField = (schema: z.ZodType, field: string) => (value: unknown): E.Either => { const result = schema.safeParse(value) return result.success ? E.right(result.data) : E.left(NEA.of({ field, message: result.error.errors[0]?.message || 'Validation failed' })) } const applicativeValidation = E.getApplicativeValidation(NEA.getSemigroup()) // Validate with error accumulation const validateUserAccumulated = (input: { name: unknown; email: unknown; age: unknown }) => sequenceS(applicativeValidation)({ name: parseWithField(nameSchema, 'name')(input.name), email: parseWithField(emailSchema, 'email')(input.email), age: parseWithField(ageSchema, 'age')(input.age) }) // Usage - all errors collected validateUserAccumulated({ name: 'A', email: 'invalid', age: -5 }) // Result: Left([ // { field: 'name', message: 'Name must be at least 2 characters' }, // { field: 'email', message: 'Invalid email format' }, // { field: 'age', message: 'Age must be non-negative' } // ]) ``` ## Best Practices ### 1. Choose the Right Pattern ```typescript // Use fail-fast (chain) when: // - Validations depend on each other // - You want to short-circuit on first failure // - Performance is critical // Use error accumulation (sequenceT/sequenceS) when: // - Validations are independent // - You want to show all errors to users // - Building form validation ``` ### 2. Structure Error Types ```typescript // Good: Structured errors with codes interface StructuredError { code: string // Machine-readable field: string // Which field failed message: string // Human-readable metadata?: unknown // Additional context } // Bad: Just strings type BadError = string ``` ### 3. Separate Schema Validation from Business Rules ```typescript // Schema validation (structure/types) - use io-ts or Zod const UserSchema = z.object({ email: z.string().email(), age: z.number() }) // Business rules - use fp-ts validation const validateBusinessRules = (user: User) => sequenceS(applicativeValidation)({ emailDomain: validateCorporateEmail(user.email), ageRestriction: validateAgeForRole(user.age, user.role) }) // Combine both const validateUser = (input: unknown) => pipe( parseSchema(input), E.chain(validateBusinessRules) ) ``` ### 4. Create Reusable Validator Combinators ```typescript // Combinator that runs multiple validators on same value const all = (...validators: Array<(a: A) => E.Either, A>>) => (value: A): E.Either, A> => pipe( validators.map(v => v(value)), results => results.reduce( (acc, result) => pipe( sequenceS(E.getApplicativeValidation(NEA.getSemigroup()))({ acc, result }), E.map(() => value) ), E.right(value) as E.Either, A> ) ) // Usage const validatePassword = all( minLength('password', 8), hasUppercase('password'), hasLowercase('password'), hasNumber('password') ) ```