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<E, A> represents a computation that can fail with error E or succeed with value A .
import * as E from 'fp-ts/Either' import { pipe } from 'fp-ts/function'
// Basic validation function signature type Validation<E, A> = E.Either<E, A>
// Simple validation returning Either
const validateNonEmpty = (field: string) => (value: string): E.Either<string, string> =>
value.trim().length > 0
? E.right(value.trim())
: E.left(${field} cannot be empty)
const validateMinLength = (field: string, min: number) => (value: string): E.Either<string, string> =>
value.length >= min
? E.right(value)
: E.left(${field} must be at least ${min} characters)
const validateEmail = (email: string): E.Either<string, string> => /^[^\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.
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.
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<string>
// Lift validation to use NonEmptyArray for errors
const validateNonEmptyV = (field: string) => (value: string): E.Either<ValidationErrors, string> =>
value.trim().length > 0
? E.right(value.trim())
: E.left(NEA.of(${field} cannot be empty))
const validateEmailV = (email: string): E.Either<ValidationErrors, string> => /^[^\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<ValidationErrors, string> =>
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<string>())
Using sequenceT and sequenceS
sequenceT - Tuple-based Combining
Combines validations into a tuple, accumulating all errors.
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<ValidationErrors, number> => { 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.
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<ValidationErrors, ValidatedUser> => 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
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<FieldError>
const fieldError = (field: string, message: string): FormErrors => NEA.of({ field, message })
const formApplicative = E.getApplicativeValidation(NEA.getSemigroup<FieldError>())
// Reusable validators const required = (field: string) => (value: string): E.Either<FormErrors, string> => 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<FormErrors, string> =>
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<FormErrors, string> =>
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<FormErrors, string> => 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<FormErrors, number> => { 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<FormErrors, number> =>
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 = <A, B>( v1: (a: A) => E.Either<FormErrors, B>, ...validators: Array<(b: B) => E.Either<FormErrors, B>> ) => (value: A): E.Either<FormErrors, B> => 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<FormErrors, ValidatedRegistration> => { 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<FormErrors, void> => 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<string, string[]> => errors.reduce((acc, err) => ({ ...acc, [err.field]: [...(acc[err.field] || []), err.message] }), {} as Record<string, string[]>)
// For React forms const getFieldErrors = (result: E.Either<FormErrors, unknown>) => (field: string): string[] => pipe( result, E.fold( errors => errors.filter(e => e.field === field).map(e => e.message), () => [] ) )
API Input Validation
Request Body Validation
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<ApiValidationError>
const apiError = (code: string, field: string, message: string): ApiErrors => NEA.of({ code, field, message })
const apiApplicative = E.getApplicativeValidation(NEA.getSemigroup<ApiValidationError>())
// Common API validators
const requiredField = <T>(field: string) => (value: T | null | undefined): E.Either<ApiErrors, T> =>
value != null
? E.right(value)
: E.left(apiError('REQUIRED', field, ${field} is required))
const stringField = (field: string) => (value: unknown): E.Either<ApiErrors, string> =>
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<ApiErrors, number> =>
typeof value === 'number' && !isNaN(value)
? E.right(value)
: E.left(apiError('INVALID_TYPE', field, ${field} must be a number))
const arrayField = <T>(field: string) => (value: unknown): E.Either<ApiErrors, T[]> =>
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<ApiErrors, string> =>
/^[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 = <T extends string>(field: string, allowed: readonly T[]) => (value: string): E.Either<ApiErrors, T> =>
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<ApiErrors, { productId: string; quantity: number }> => {
if (typeof item !== 'object' || item === null) {
return E.left(apiError('INVALID_TYPE', items[${index}], 'Item must be an object'))
}
const obj = item as Record<string, unknown>
return sequenceS(apiApplicative)({
productId: pipe(
requiredField<unknown>(items[${index}].productId)(obj.productId),
E.chain(stringField(items[${index}].productId)),
E.chain(uuidField(items[${index}].productId))
),
quantity: pipe(
requiredField<unknown>(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<ApiErrors, ValidatedOrder['shippingAddress']> => { if (typeof address !== 'object' || address === null) { return E.left(apiError('INVALID_TYPE', 'shippingAddress', 'Shipping address must be an object')) }
const obj = address as Record<string, unknown>
return sequenceS(apiApplicative)({ street: pipe( requiredField<unknown>('shippingAddress.street')(obj.street), E.chain(stringField('shippingAddress.street')) ), city: pipe( requiredField<unknown>('shippingAddress.city')(obj.city), E.chain(stringField('shippingAddress.city')) ), postalCode: pipe( requiredField<unknown>('shippingAddress.postalCode')(obj.postalCode), E.chain(stringField('shippingAddress.postalCode')) ), country: pipe( requiredField<unknown>('shippingAddress.country')(obj.country), E.chain(stringField('shippingAddress.country')) ) }) }
const validateCreateOrder = (body: unknown): E.Either<ApiErrors, ValidatedOrder> => { if (typeof body !== 'object' || body === null) { return E.left(apiError('INVALID_TYPE', 'body', 'Request body must be an object')) }
const obj = body as Record<string, unknown>
// Validate items array with accumulation const validateItems = (items: unknown[]): E.Either<ApiErrors, Array<{ productId: string; quantity: number }>> => { 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<ApiErrors, Array<{ productId: string; quantity: number }>>
)
}
return pipe( sequenceS(apiApplicative)({ customerId: pipe( requiredField<unknown>('customerId')(obj.customerId), E.chain(stringField('customerId')), E.chain(uuidField('customerId')) ), items: pipe( requiredField<unknown>('items')(obj.items), E.chain(arrayField<unknown>('items')), E.chain(validateItems) ), shippingAddress: pipe( requiredField<unknown>('shippingAddress')(obj.shippingAddress), E.chain(validateAddress) ), paymentMethod: pipe( requiredField<unknown>('paymentMethod')(obj.paymentMethod), E.chain(stringField('paymentMethod')), E.chain(enumField('paymentMethod', ['credit_card', 'paypal', 'bank_transfer'] as const)) ) }) ) }
// Express middleware example const validateRequest = <T>(validator: (body: unknown) => E.Either<ApiErrors, T>) => (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
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<string, unknown> }
const validationError = ( code: ValidationErrorCode, field: string, message: string, metadata?: Record<string, unknown> ): DomainValidationError => ({ _tag: 'ValidationError', code, field, message, metadata })
type DomainErrors = NEA.NonEmptyArray<DomainValidationError>
// Helper to create single error const singleError = ( code: ValidationErrorCode, field: string, message: string, metadata?: Record<string, unknown> ): DomainErrors => NEA.of(validationError(code, field, message, metadata))
// Validators with rich error metadata
const minLengthWithMeta = (field: string, min: number) => (value: string): E.Either<DomainErrors, string> =>
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
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<typeof UserCodec>
// Convert io-ts errors to our error format interface IoTsValidationError { field: string message: string }
const fromIoTsErrors = (errors: t.Errors): NEA.NonEmptyArray<IoTsValidationError> => {
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<IoTsValidationError>
}
// Decode with accumulated errors const decodeUser = (input: unknown): E.Either<NEA.NonEmptyArray<IoTsValidationError>, User> => pipe( UserCodec.decode(input), E.mapLeft(fromIoTsErrors) )
// Combine io-ts decoding with custom validation const validateAndDecodeUser = (input: unknown): E.Either<NEA.NonEmptyArray<IoTsValidationError>, 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<IoTsValidationError>)
: E.right(user)
})
)
Custom io-ts Codecs with Validation
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<string, EmailBrand>
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<string, NonEmptyStringBrand>
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<number, PositiveIntBrand>
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<typeof ValidatedUserCodec>
Integration with Zod
Zod with fp-ts Either
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<typeof UserSchema>
// Convert Zod errors to our format interface ZodValidationError { field: string message: string }
const fromZodError = (error: z.ZodError): NEA.NonEmptyArray<ZodValidationError> => { const errors = error.errors.map(err => ({ field: err.path.join('.') || 'root', message: err.message })) return errors as NEA.NonEmptyArray<ZodValidationError> }
// Parse with Either const parseUser = (input: unknown): E.Either<NEA.NonEmptyArray<ZodValidationError>, 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<NEA.NonEmptyArray<ZodValidationError>, 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<ZodValidationError>)
: E.right(user)
})
)
Zod Refinements with Error Accumulation
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<ValidationError>
const parseWithField = <T>(schema: z.ZodType<T>, field: string) => (value: unknown): E.Either<ValidationErrors, T> => { 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<ValidationError>())
// 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
- Choose the Right Pattern
// 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
- Structure Error Types
// 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
- Separate Schema Validation from Business Rules
// 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) )
- Create Reusable Validator Combinators
// Combinator that runs multiple validators on same value const all = <E, A>(...validators: Array<(a: A) => E.Either<NEA.NonEmptyArray<E>, A>>) => (value: A): E.Either<NEA.NonEmptyArray<E>, A> => pipe( validators.map(v => v(value)), results => results.reduce( (acc, result) => pipe( sequenceS(E.getApplicativeValidation(NEA.getSemigroup<E>()))({ acc, result }), E.map(() => value) ), E.right(value) as E.Either<NEA.NonEmptyArray<E>, A> ) )
// Usage const validatePassword = all( minLength('password', 8), hasUppercase('password'), hasLowercase('password'), hasNumber('password') )