Skip to content

V (Validation)

Validation is for independent checks where callers need every error in one pass. It uses the same runtime representation as Result, but its error side is guaranteed to be non-empty.

Validation has two dual operations. Their paired signatures are in the complete dual-call catalogue.

type NonEmptyArray<E> = readonly [E, ...E[]]
type Validation<A, E> = Result<A, NonEmptyArray<E>>
valid<A>(value: A): Validation<A, never>
invalid<E>(error: E): Validation<never, E>
fromResult<A, E>(result: Result<A, E>): Validation<A, E>
fromPredicate<A, E>(
value: A,
predicate: (value: A) => boolean,
onFalse: (value: A) => E,
): Validation<A, E>
fromPredicate<A, E>(
predicate: (value: A) => boolean,
onFalse: (value: A) => E,
): (value: A) => Validation<A, E>

invalid(error) stores one error in a NonEmptyArray. fromResult preserves a success and wraps one Result error. Type-guard predicates narrow the successful value, and onFalse is lazy.

all<T extends readonly Validation<unknown, unknown>[]>(
validations: T,
): Validation<ValuesOf<T>, ErrorsOf<T>>
traverse<A, B, E>(
values: readonly A[],
validate: (value: A) => Validation<B, E>,
): Validation<B[], E>
traverse<A, B, E>(
validate: (value: A) => Validation<B, E>,
): (values: readonly A[]) => Validation<B[], E>

all preserves heterogeneous tuple types and flattens every validation error in stable input order. traverse calls the validator for every input, even after failures. Empty inputs return valid([]).

import { pipe } from '@stopcock/fp'
import * as R from '@stopcock/fp/result'
import * as V from '@stopcock/fp/validation'
type FieldError = { field: string; message: string }
const signup = V.all([
V.fromPredicate(
email,
(value: string) => value.includes('@'),
() => ({
field: 'email',
message: 'invalid email',
}),
),
pipe(
password,
V.fromPredicate(
(value: string) => value.length >= 8,
() => ({
field: 'password',
message: 'too short',
}),
),
),
] as const)
R.match(signup, {
err: (errors) => showErrors(errors),
ok: ([validEmail, validPassword]: [string, string]) => submit(validEmail, validPassword),
})

Validation is structurally a Result, so use existing Result transforms and extraction:

R.map(validation, normalize)
pipe(validation, R.map(normalize))
pipe(validation, R.match({ err: showErrors, ok: submit }))
pipe(
validation,
R.getOrElse(() => fallback),
)

There is deliberately no flatMap in the validation module. Dependent sequential checks are fail-fast and belong in R.flatMap; use Validation only to accumulate checks that can all run independently.