Skip to content

Validation — collecting errors

If you’ve ever fixed a validation error on a form, resubmitted, and been told about a different error — you’ve felt the frustration of validation that stops at the first failure. Validation<E, A> runs all checks and collects every failure in one pass. Users see everything wrong at once, not one problem at a time.

When validating a form, Result’s behavior is unhelpful:

pipe(
	validateName(form.name),
	Result.chain(() => validateEmail(form.email)),
	Result.chain(() => validateAge(form.age)),
);

If validateName fails, the pipeline stops. The user sees one error, fixes it, submits again, and sees the next one. You want to show all three errors at once.

Validation accumulates errors across independent checks. When you combine two invalid validations, both error lists are merged:

import { pipe } from "@nlozgachev/pipelined/composition";
import { Validation } from "@nlozgachev/pipelined/core";

const validateName = (name: string): Validation<string, string> =>
	name.length > 0
		? Validation.valid(name)
		: Validation.invalid("Name is required");

const validateAge = (age: number): Validation<string, number> =>
	age >= 0
		? Validation.valid(age)
		: Validation.invalid("Age must be non-negative");

Running both checks with ap collects all failures:

pipe(
	Validation.valid((name: string) => (age: number) => ({ name, age })),
	Validation.ap(validateName("")),
	Validation.ap(validateAge(-1)),
);
// Invalid(["Name is required", "Age must be non-negative"])

If both pass, you get the assembled value:

pipe(
	Validation.valid((name: string) => (age: number) => ({ name, age })),
	Validation.ap(validateName("Alice")),
	Validation.ap(validateAge(30)),
);
// Valid({ name: "Alice", age: 30 })
Validation.valid(42); // Valid(42)
Validation.invalid("too short"); // Invalid(["too short"]) — single error
Validation.invalidAll(["too short", "missing digits"]); // Invalid([...]) — multiple errors

invalid wraps a single error in a list. invalidAll accepts a NonEmptyList directly, useful when you already have a collection of errors.

ap is what sets Validation apart from Result. The pattern is: start with your constructor function wrapped in Validation.valid, then apply each validated argument with ap:

// Constructor: (field1) => (field2) => (field3) => result
const build = (email: string) => (password: string) => (age: number) => ({
	email,
	password,
	age,
});

pipe(
	Validation.valid(build),
	Validation.ap(validateEmail(form.email)), // applies first arg
	Validation.ap(validatePassword(form.password)), // applies second arg
	Validation.ap(validateAge(form.age)), // applies third arg
);

Each ap step:

  • If both sides are valid, applies the function to the value
  • If either side is invalid, merges both error lists into a single Invalid

The key property: all ap steps run regardless of prior failures. This is what allows all errors to be collected.

The ap pattern looks unusual at first. A simpler read: you’re lifting a curried constructor and applying each validated argument one by one. If the constructor shape feels awkward, productAll often expresses the same intent more directly — pass a list of validations, get back a list of valid values.

product takes two independent validations and combines them into a single Validation holding a tuple of both values. If either fails, errors from both sides are merged:

Validation.product(
	Validation.valid("alice"),
	Validation.valid(30),
); // Valid(["alice", 30])

Validation.product(
	Validation.invalid("Name required"),
	Validation.invalid("Age must be >= 0"),
); // Invalid(["Name required", "Age must be >= 0"])

This is the binary building block for combining two independent checks when you want both values afterwards.

Combining many validations with productAll

Section titled “Combining many validations with productAll”

productAll takes a non-empty list of validations, runs all of them, and either collects all values or accumulates all errors:

Validation.productAll([
	validateName(form.name),
	validateEmail(form.email),
	validateAge(form.age),
]);
// Valid([name, email, age]) — if all pass
// Invalid([...all errors]) — if any fail

Because the input is a NonEmptyList, the empty-array case is a compile-time error — the return type is always Validation<E, readonly A[]> with no undefined.

map transforms the valid value, leaving Invalid untouched:

pipe(
	Validation.valid(5),
	Validation.map((n) => n * 2),
); // Valid(10)
pipe(
	Validation.invalid("oops"),
	Validation.map((n) => n * 2),
); // Invalid(["oops"])

getOrElse — provide a fallback as a thunk () => B, called only when the result is Invalid. The fallback can be a different type, widening the result to the union of both:

pipe(Validation.valid(5), Validation.getOrElse(() => 0)); // 5
pipe(Validation.invalid("oops"), Validation.getOrElse(() => 0)); // 0
pipe(Validation.invalid("oops"), Validation.getOrElse(() => null)); // null — typed as number | null

match — handle each case explicitly. The invalid handler receives the full error list. fold is the positional form — invalid handler first, valid handler second:

pipe(
	validation,
	Validation.match({
		valid: (value) => renderSuccess(value),
		invalid: (errors) => renderErrors(errors), // errors: NonEmptyList<string>
	}),
);

recover provides a fallback Validation when the result is Invalid. The fallback receives the accumulated error list, so you can inspect which errors occurred and decide how to recover:

pipe(
	validateConfig(input),
	Validation.recover((errors) => {
		console.warn("Validation failed:", errors);
		return Validation.valid(defaultConfig);
	}),
);

When the input is already Valid, the fallback is never called:

pipe(
	Validation.valid(42),
	Validation.recover((_errors) => Validation.valid(0)),
); // Valid(42) — fallback skipped

Result — stops at first error

sequenceDiagram
  participant P as your code
  participant N as check name
  participant E as check email
  participant A as check age

  P->>N: name = ""
  N-->>P: Err("Name required")
  Note over E,A: never called

Validation — all checks run

sequenceDiagram
  participant P as your code
  participant N as check name
  participant E as check email
  participant A as check age

  P->>N: name = ""
  N-->>P: Invalid("Name required")
  P->>E: email = "bad"
  E-->>P: Invalid("Invalid email")
  P->>A: age = -1
  A-->>P: Invalid("Age must be >= 0")
  Note over P: Invalid(["Name required", "Invalid email", "Age must be >= 0"])

Use Validation when:

  • You’re validating multiple independent fields and want all errors at once
  • The consumer of your output (e.g., a form UI) needs the complete list of what went wrong

Use Result when:

  • Each step depends on the previous one succeeding — convert to Result and use Result.chain for dependent validation, then convert back
  • You want to fail fast and stop processing as soon as something goes wrong
  • The operation isn’t about validation — it’s about control flow

In practice, many real-world scenarios mix both: use Validation to check individual fields, then use Result to sequence the side effects once the data is known to be valid.