Skip to content

Validation — Accumulating Errors

Using fail-fast error structures (Result, try/catch) for form or schema validation short-circuits on the first failure, hiding subsequent field errors until previous ones are corrected.

Validation<E, A> models validation outcomes as a discriminated union: Passed<A> (valid value) or Failed<E> (accumulated non-empty list of errors). Instead of short-circuiting, it evaluates independent checks and merges all failures together.


To begin validating, we lift our data checks into the Validation context:

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

// Representing a successful validation check
const pass = Validation.make.passed("Alice"); // Validation<never, string>

// Representing a single failure
const fail = Validation.make.failed("Username must be at least 3 characters"); // Validation<string, never>

Under the hood, all failures in Validation are collected inside an Arr.NonEmpty (a type-safe array guaranteed to contain at least one element). When you call Validation.make.failed(err), the library automatically wraps your error in an array container.

If you already have an array of errors and want to lift it directly, you can use failedAll:

const multipleFails = Validation.make.failedAll([
  "Email is malformed",
  "Email domain is not allowed",
]);

You can build reusable, rule-specific validation checkers using from.Predicate:

const validatePasswordLength = Validation.from.Predicate(
  (s: string) => s.length >= 8,
  (s) => `Password of length ${s.length} is too short (minimum 8 characters)`,
);

The second argument receives the original input, allowing you to format descriptive, clear feedback for your users.


What makes Validation structurally different from Result is how we combine multiple independent checks.

The primary tool for this is ap (short for apply). The pattern begins by wrapping a curried constructor function in passed, and then applying each validated argument one-by-one:

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

// A constructor function representing our valid target structure
const createUser = (name: string) => (email: string) => (age: number) => ({
  name,
  email,
  age,
});

const result = pipe(
  Validation.make.passed(createUser),
  Validation.ap(validateName(form.name)),   // Applies name check
  Validation.ap(validateEmail(form.email)), // Applies email check
  Validation.ap(validateAge(form.age)),     // Applies age check
);

Let’s dissect what happens when this pipeline executes. Each ap step inspects both sides:

  • If both the function and the argument have passed, the argument value is applied to the function.
  • If either the function or the argument has failed, the errors are gathered.
  • If both have failed, their respective error lists are merged.

Because each argument is validated independently before being combined, all validation checks are guaranteed to run, and every failure is gathered into a single consolidated Failed container.


Alternative Combinators: product and productAll

Section titled “Alternative Combinators: product and productAll”

If the curried ap pattern feels unfamiliar or syntactically complex, Validation provides simpler, array-based alternatives.

product takes two independent validations and merges them into a single Validation carrying a tuple of both values:

const combined = Validation.product(
  validateName(form.name),
  validateAge(form.age),
); // Passed([name, age]) or Failed([nameErrors..., ageErrors...])

If either validation has failed, the errors from both sides are collected and merged.

productAll accepts an array of validations, runs all of them, and returns either a Passed tuple containing all successfully validated values, or a Failed list containing every accumulated error:

const formValidation = Validation.productAll([
  validateName(form.name),
  validateEmail(form.email),
  validateAge(form.age),
]);
// Passed([name, email, age]) — if all pass
// Failed([...all errors]) — if any fail

Because productAll expects an Arr.NonEmpty (a non-empty array) of validations, you are guaranteed that the input list has at least one validation check, carrying a type-safe array of values, completely avoiding the possibility of empty array inputs or undefined states at compile time.


You can transform the success value inside a Passed container without worrying about the failure branch using map:

pipe(
  validateAge(input),
  Validation.map((age) => age * 365), // Converts age in years to age in days
);

If the validation has failed, map does nothing and lets the accumulated errors propagate.


Once all checks have run and the errors have been accumulated, you must exit the Validation context at the edge of your pipeline.

getOrElse extracts the validated value from a Passed container, or returns a safe fallback value if the validations failed:

pipe(
  formValidation,
  Validation.getOrElse(() => defaultUserData),
);

As with other modules in this library, getOrElse expects a function (a thunk) to defer evaluating the fallback value, saving execution costs if the validation checks pass successfully.

To drive distinct UI rendering or business branches based on the outcome, you can analyze both cases using match or fold:

// Named cases using match
const view = pipe(
  formValidation,
  Validation.match({
    passed: (data) => renderSuccessDashboard(data),
    failed: (errors) => renderErrorList(errors), // errors is Arr.NonEmpty<E>
  }),
);

// Positional callbacks using fold (failed handler first)
const status = pipe(
  formValidation,
  Validation.fold(
    (errors) => `Failed with ${errors.length} errors`,
    (data) => `Success: user ${data[0]} verified`,
  ),
);

When you want to log or inspect validation failures mid-pipeline without altering the validation flow, you can use tapError. It executes a side-effectful callback only if the validation has failed, passing the full list of accumulated errors:

pipe(
  formValidation,
  Validation.tapError((errors) => {
    logger.warn(`Form validation failed with ${errors.length} errors`, { errors });
  }),
);

recover provides a fallback Validation when validation has failed. It passes the accumulated error list to your fallback function, allowing you to inspect what went wrong and decide how to recover dynamically:

pipe(
  validatePayload(input),
  Validation.recover((errors) => {
    console.warn("Payload validation failed. Using default configuration.", errors);
    return Validation.make.passed(defaultPayload);
  }),
);

Because software systems use a variety of modeling types, you can translate Validation to and from other modules.

If you only care about obtaining a valid value and do not need to report the reasons for failure, you can downgrade the Validation to a Maybe using to.Maybe:

const maybeValidData = Validation.to.Maybe(formValidation); // Some(data) or None

When incorporating an operation that throws or fail-fast checks (like a Result parser) into an accumulating validation flow, you can lift it using from.Result:

const emailCheck = Validation.from.Result(parseEmail(input)); // Passed(email) or Failed([err])

The single error from the Err is wrapped in a type-safe Arr.NonEmpty automatically.

Sequencing actions by converting to Result

Section titled “Sequencing actions by converting to Result”

Validation is outstanding for running parallel, independent checks. However, once you have established that the data is 100% valid, you typically need to run sequential side effects that can fail (like saving to a database, sending a request, or writing to disk).

For this, you should hand off execution to a Result pipeline using to.Result. If you want to merge accumulated validation errors into a single error string or composite error object, pass a combiner function to to.Result:

pipe(
  Validation.productAll([validateName(form.name), validateEmail(form.email)]),
  Validation.to.Result((errors) => errors.join("; ")), // Passed becomes Ok, Failed becomes Err("err1; err2")
  Result.chain((data) => db.saveUser(data)), // Sequential, fail-fast side effect
  Result.getOrElse(() => null),
);

Calling Validation.to.Result without arguments retains the raw Arr.NonEmpty<E> error list inside Err.

This hand-off represents a highly common, elegant pattern in production applications: use Validation to gather all input friction, convert to Result once the data is clean, and use Result.chain to sequence sequential database or network actions.


A developer familiar with other modules in this library (like Result or Maybe) might wonder why Validation completely omits bind and bindTo helpers.

This omission is a deliberate architectural and structural design choice.

bind and bindTo represent monadic sequencing. In a sequential pipeline:

// NOT supported in Validation
pipe(
  getUser(userId),
  Validation.bindTo("user"),
  Validation.bind("prefs", ({ user }) => getPreferences(user.id)),
);

Step B (prefs) requires the successful output of Step A (user). If Step A fails, Step B cannot run. This sequential dependency naturally defeats the primary, first-class purpose of Validationindependent error accumulation. If steps are dependent, we cannot evaluate them in parallel, and we cannot gather all errors across all branches at the same time.

For sequenced pipelines that depend on prior steps, you should use Result (which naturally supports bind and bindTo for fail-fast chaining). If you need to validate independent inputs, keep them in Validation. Once the data is validated, you can cleanly hand it off to a Result pipeline using Validation.to.Result to perform sequential actions.


To combine multiple independent validation checks into a single validated object, you can use Validation.struct.

Unlike Result.struct or Maybe.struct (which short-circuit on the first failure), Validation.struct accumulates errors from all failed branches into a single Failed list:

const validatedUser = Validation.struct({
  name: Validation.from.Predicate((s: string) => s.length > 0, () => "Name is required")(""),
  age: Validation.from.Predicate((n: number) => n >= 18, () => "Must be at least 18")(16),
  role: Validation.make.passed("user"),
}); 
// Failed(["Name is required", "Must be at least 18"])

If all validation fields pass successfully, it returns a Passed container containing the fully constructed record:

const passedUser = Validation.struct({
  name: Validation.make.passed("Alice"),
  age: Validation.make.passed(30),
  role: Validation.make.passed("admin"),
});
// Passed({ name: "Alice", age: 30, role: "admin" })

When you need to perform validation checks that require asynchronous operations (such as making a network request to verify email availability, or querying a database to check if a username is taken), you can use Task.Validation<E, A>.

Under the hood, Task.Validation is an alias for Task<Validation<E, A>>. Like Validation, it concurrently evaluates independent checks and aggregates all failures into a single list rather than failing fast.

To run multiple asynchronous validation checks in parallel and accumulate any failures, combine them using Task.Validation.struct:

import { Task } from "@nlozgachev/pipelined/core";

const validateUserAsync = Task.Validation.struct({
  username: checkUsernameUnique(username), // Task.Validation<string, string>
  email: checkEmailNotBlocked(email),       // Task.Validation<string, string>
}); // Task.Validation<string, { username: string; email: string }>

If any check fails, the task resolves to a Failed variant containing the accumulated list of all errors.

Because validation processes are designed to accumulate multiple failures, we often need to format or log these errors mid-pipeline. Task.Validation provides dedicated error-mapping combinators for this:

  • mapError: Transforms all accumulated errors in the list using a function (e: E) => F.
  • tapError: Executes a side effect (such as logging warnings) on the accumulated list of errors without modifying the pipeline’s value.
import { pipe } from "@nlozgachev/pipelined/composition";
import { Task } from "@nlozgachev/pipelined/core";

const validatedProfile = pipe(
  validateUserAsync,
  Task.Validation.mapError((err) => `Server Validation: ${err}`),
  Task.Validation.tapError((errors) => {
    logger.warn(`Profile validation failed with ${errors.length} errors`, { errors });
  })
);

  • Multi-field web form validation: In registration forms, profile editors, and checkout panels, users frequently submit multiple invalid fields at once. Short-circuiting error handlers stop on the first error, forcing frustrating trial-and-error submissions. Validation aggregates all field errors into a single collection, allowing UIs to render feedback on every invalid input simultaneously.
  • Batch data ingestion and bulk import diagnostics: When importing CSV spreadsheets, parsing configuration files, or validating batch API payloads, systems need a full accounting of all invalid records. Validation.struct and Validation.all evaluate all records in parallel, gathering every schema and domain violation into a complete diagnostic report rather than halting on row one.
  • Multi-rule entity policy checks: When evaluating complex entity constraints (such as password strength requirements, credit assessment criteria, or compliance checklists), Validation runs all checks independently and reports every unmet condition at once for comprehensive user feedback.
  • Bridging validation accumulation with fail-fast execution (Validation.to.Result, Validation.from.Result): In web endpoints, validating an incoming request body requires accumulating all field errors with Validation, but subsequent database operations require fail-fast sequential chaining with Result. Validation.to.Result bridges these patterns cleanly, preserving all collected errors while transitioning into standard sequential pipelines.
  • Contextual error tagging across nested forms (Validation.mapError): In nested forms and multi-step wizards, child components validate their own sub-fields. Validation.mapError allows parent forms to namespace child errors (e.g. prefixing address.zipCode) before merging, keeping error messages structured and localized.