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 error

Validation (Accumulation)

// Collects all errors
const result = zipOrAccumulate(
  validateUsername(form.username),
  validateEmail(form.email),
  validatePassword(form.password)
)

// Shows ALL errors at once

Next Steps