Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add whatiskadudoing/fp-ts-skills --skill "fp-ts-validation"
Install specific skill from multi-skill repository
# Description
Validation patterns using fp-ts with error accumulation, form validation, and API input validation
# SKILL.md
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<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
1. 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
2. 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
3. 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)
)
4. 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')
)
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.