Validation
Accumulate validation errors instead of short-circuiting on the first failure
Overview
Traditional Either-based validation stops at the first error. The validation helpers allow you to collect ALL validation errors and present them to the user at once, providing a better user experience.
Inspired by Arrow-kt's validation patterns, these utilities use NonEmptyList to guarantee that error collections are never empty when validation fails.
Form Validation
Validate multiple fields and collect all errors
import { zipOrAccumulate, Left, Right } from 'kotlinify-ts'
type FormData = {
username?: string
email?: string
password?: string
}
const validateUsername = (username?: string) =>
username && username.length >= 3
? Right(username)
: Left('Username must be at least 3 characters')
const validateEmail = (email?: string) =>
email && email.includes('@')
? Right(email)
: Left('Email must contain @')
const validatePassword = (password?: string) =>
password && password.length >= 8
? Right(password)
: Left('Password must be at least 8 characters')
const validateForm = (form: FormData) =>
zipOrAccumulate(
validateUsername(form.username),
validateEmail(form.email),
validatePassword(form.password)
)
// Usage
const result = validateForm({
username: 'ab',
email: 'invalid',
password: '123'
})
if (result.isLeft) {
const errors = result.getLeft()
console.log('Validation errors:', errors)
// ['Username must be at least 3 characters',
// 'Email must contain @',
// 'Password must be at least 8 characters']
}Bulk Validation
Validate arrays and collect all errors
import { mapOrAccumulate, Left, Right } from 'kotlinify-ts'
type User = { name: string; age: number }
const validateUser = (user: Partial<User>) => {
if (!user.name || user.name.length < 2) {
return Left({ field: 'name', message: 'Name too short' })
}
if (!user.age || user.age < 18) {
return Left({ field: 'age', message: 'Must be 18+' })
}
return Right(user as User)
}
const users = [
{ name: 'Alice', age: 25 },
{ name: 'B', age: 30 },
{ name: 'Charlie', age: 15 }
]
const result = mapOrAccumulate(users, validateUser)
if (result.isLeft) {
const errors = result.getLeft()
// NonEmptyList of all validation errors
errors.forEach(err => {
console.log(`${err.field}: ${err.message}`)
})
}NonEmptyList
Guaranteed non-empty error collections
import { NonEmptyList } from 'kotlinify-ts'
// Create a NonEmptyList (guaranteed to have at least one element)
const errors = NonEmptyList.of('error1', 'error2', 'error3')
console.log(errors.head) // 'error1'
console.log(errors.tail) // ['error2', 'error3']
console.log(errors.length) // 3
// Create from array (returns null if empty)
const fromArray = NonEmptyList.fromArray(['a', 'b'])
// NonEmptyList<string>
const empty = NonEmptyList.fromArray([])
// null
// NonEmptyList extends Array, so all array methods work
const doubled = errors.map(e => e + e)
const filtered = errors.filter(e => e.includes('1'))EitherNel Type Alias
Either with NonEmptyList of errors
import { EitherNel, Left, Right, NonEmptyList } from 'kotlinify-ts'
// EitherNel<E, A> is Either<NonEmptyList<E>, A>
type ValidationResult<T> = EitherNel<string, T>
function validate(input: string): ValidationResult<number> {
const num = parseInt(input)
if (isNaN(num)) {
return Left(NonEmptyList.of('Not a number'))
}
const errors: string[] = []
if (num < 0) errors.push('Must be positive')
if (num > 100) errors.push('Must be <= 100')
if (errors.length > 0) {
return Left(NonEmptyList.of(errors[0], ...errors.slice(1)))
}
return Right(num)
}zipOrAccumulate
Combine multiple validations
import { zipOrAccumulate } from 'kotlinify-ts'
// Combine 2 validations
const result2 = zipOrAccumulate(
validateField1(data.field1),
validateField2(data.field2)
)
// Either<NonEmptyList<Error>, [Field1, Field2]>
// Combine 3 validations
const result3 = zipOrAccumulate(
validateField1(data.field1),
validateField2(data.field2),
validateField3(data.field3)
)
// Either<NonEmptyList<Error>, [Field1, Field2, Field3]>
// Combine 4 or more validations
const result4 = zipOrAccumulate(
validateField1(data.field1),
validateField2(data.field2),
validateField3(data.field3),
validateField4(data.field4)
)
// Either<NonEmptyList<Error>, [Field1, Field2, Field3, Field4]>Real-World Example
API request validation
import { zipOrAccumulate, mapOrAccumulate, Left, Right } from 'kotlinify-ts'
type ApiRequest = {
endpoint: string
method: string
body: unknown
}
const validateEndpoint = (endpoint?: string) =>
endpoint && endpoint.startsWith('/')
? Right(endpoint)
: Left('Endpoint must start with /')
const validateMethod = (method?: string) =>
method && ['GET', 'POST', 'PUT', 'DELETE'].includes(method)
? Right(method)
: Left('Invalid HTTP method')
const validateBody = (body?: unknown) =>
body !== undefined
? Right(body)
: Left('Body is required')
const validateRequest = (request: Partial<ApiRequest>) =>
zipOrAccumulate(
validateEndpoint(request.endpoint),
validateMethod(request.method),
validateBody(request.body)
)
// Validate multiple requests
const requests = [
{ endpoint: '/users', method: 'POST', body: { name: 'Alice' } },
{ endpoint: 'users', method: 'PATCH', body: undefined }
]
const results = mapOrAccumulate(requests, validateRequest)
if (results.isLeft) {
console.error('Validation errors:', results.getLeft())
} else {
console.log('All requests valid:', results.getRight())
}Comparison: Short-Circuit vs Accumulation
Traditional Either (Short-Circuit)
// Stops at first error
const result = validateUsername(form.username)
.flatMap(username =>
validateEmail(form.email)
.flatMap(email =>
validatePassword(form.password)
.map(password => ({
username,
email,
password
}))
)
)
// Only shows FIRST errorValidation (Accumulation)
// Collects all errors
const result = zipOrAccumulate(
validateUsername(form.username),
validateEmail(form.email),
validatePassword(form.password)
)
// Shows ALL errors at once