Skip to content

Validation — Accumulating Errors

We have all experienced a frustrating user interface pattern: you fill out a long form, hit submit, and are presented with a red validation error indicating your password is too short. You fix it, submit again, only to be told that your email address is malformed. You fix that, submit once more, and receive a third warning about your postal code.

This trial-and-error cycle is the direct result of a design choice in our code. When we use standard try/catch blocks, conditional guards, or Result containers to validate input, we are using a fail-fast model. This model short-circuits at the very first failure:

// Result-based short-circuiting:
pipe(
  validateName(form.name),
  Result.chain(() => validateEmail(form.email)),
  Result.chain(() => validateAge(form.age)),
);

If validateName fails, the execution stops. The subsequent checks for email and age are never even evaluated. While this short-circuiting behavior is correct for sequential operations where step B depends on the success of step A, it is highly unhelpful for validating independent data fields in forms, API payloads, or configuration files.

Validation<E, A> is a data structure designed specifically to address this problem. It represents either a Passed<A> success or a Failed<E> failure containing a list of accumulated errors. Instead of short-circuiting, it runs all checks independently and merges all errors 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.passed("Alice"); // Validation<never, string>

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

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

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

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

You can build reusable, rule-specific validation checkers using fromPredicate:

const validatePasswordLength = Validation.fromPredicate(
  (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.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 a NonEmptyList (a non-empty array) of validations, you are guaranteed to receive a compiled result 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 NonEmptyList<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.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 toMaybe:

const maybeValidData = Validation.toMaybe(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 fromResult:

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

The single error from the Err is wrapped in a type-safe NonEmptyList 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 toResult:

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

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.toResult 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.fromPredicate((s: string) => s.length > 0, () => "Name is required")(""),
  age: Validation.fromPredicate((n: number) => n >= 18, () => "Must be at least 18")(16),
  role: Validation.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.passed("Alice"),
  age: Validation.passed(30),
  role: Validation.passed("admin"),
});
// Passed({ name: "Alice", age: 30, role: "admin" })

  • Checks are independent: Validating form fields, structural payload parsing, or configuration sheets.
  • You need comprehensive feedback: You want to display all errors at once to a user or log them all to an audit sheet.
  • The combinations are parallel: You are fanning out data to multiple checkers simultaneously.
  • Checks are dependent: Validating step B requires step A to have succeeded (e.g. validating an address requires the user record to have been successfully fetched first).
  • You want to fail-fast: Halting execution immediately at the first sign of friction is the desired control flow behavior.
  • The operation is a side effect: Writing files, connecting to networks, or querying databases.