Skip to content

Validation — Accumulating Errors

1.51 kB minified + gzipped

A registration form validates a username, an email address, and a password. A check that returns after the first invalid field makes the user fix one problem, submit, and discover the next one; the same round trip repeats for every field that was already wrong. The form has all three values at once, so it can report all three problems at once.

Validation<E, A> represents a check that either passed with a value or failed with a non-empty list of errors.

  • struct combines already-created field results and concatenates every failed field’s errors in own-key order.
  • apply combines a wrapped function and a wrapped argument; when both fail, the function’s errors come first.
  • map and mapError act on their matching variant, while match receives the complete error list.
  • There is no chain. A check that needs another check’s output must wait for a passed value, which is the shape Result provides.

Reach for Validation when independent fields must each report their own error. Result stops at the first failure, which fits a sequence of dependent steps. The choosing guide compares the error containers side by side.

A registration form needs to check a username, email address, and password. Reporting all invalid fields together lets the user correct them in one pass.

struct receives already-created field results and collects errors from every failed field. It returns the combined values only when all fields pass:

flowchart TD
  Fields["Username, email, and password checks"] --> Collect["struct inspects every field"]
  Collect --> Failed{"Any Failed?"}
  Failed -->|No| Passed["Passed(registration details)"]
  Failed -->|Yes| Errors["Failed(errors in field order)"]

For a failed username followed by a failed email, the error list contains the username errors first, then the email errors. struct processes fields in own-key enumeration order and preserves the order within each field’s error list.

A registration form may return after its first invalid field:

function checkRegistration(input: { username: string; email: string }): string | null {
  if (input.username.trim() === "") return "Username is required";
  if (!input.email.includes("@")) return "Email is invalid";
  return null;
}

The user must fix the username and submit again before seeing the email error. A single Validation.from.Predicate makes one check return a value or an error list:

import { Validation } from "@pplnd/core";

const checkUsername = Validation.from.Predicate(
  (username: string) => username.trim().length > 0,
  () => "Username is required",
);

checkUsername(""); // Failed(["Username is required"])

Independent fields can be checked together. A rule comparing the username and password needs both validated values, so the example converts to Result before that dependent check. The input has already been decoded into strings by the caller.

import { pipe, Result, Validation } from "@pplnd/core";

interface RegistrationInput {
  username: string;
  email: string;
  password: string;
}

type FieldError = "USERNAME_REQUIRED" | "INVALID_EMAIL" | "PASSWORD_TOO_SHORT";
type RegistrationResponse = { status: number; body: string };

const checkUsername = Validation.from.Predicate(
  (username: string) => username.trim().length > 0,
  () => "USERNAME_REQUIRED" as const,
);
const checkEmail = Validation.from.Predicate(
  (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()),
  () => "INVALID_EMAIL" as const,
);
const checkPassword = Validation.from.Predicate(
  (password: string) => password.length >= 12,
  () => "PASSWORD_TOO_SHORT" as const,
);

const validateRegistration = (input: RegistrationInput): Validation<FieldError, RegistrationInput> =>
  pipe(
    Validation.struct({
      username: checkUsername(input.username),
      email: checkEmail(input.email),
      password: checkPassword(input.password),
    }),
    Validation.map((registration) => ({
      ...registration,
      username: registration.username.trim(),
      email: registration.email.trim().toLowerCase(),
    })),
  );

const registrationResponse = (input: RegistrationInput): RegistrationResponse =>
  pipe(
    validateRegistration(input),
    Validation.to.Result((errors) => errors.join(", ")),
    Result.ensure(
      (registration) => registration.password !== registration.username,
      () => "PASSWORD_MATCHES_USERNAME",
    ),
    Result.match({
      ok: (registration) => ({ status: 200, body: `Ready to register ${registration.username}` }),
      err: (error) => ({ status: 400, body: error }),
    }),
  );

console.log(registrationResponse({ username: "", email: "invalid", password: "long-enough-password" }));
// { status: 400, body: "USERNAME_REQUIRED, INVALID_EMAIL" }
console.log(registrationResponse({ username: "longusername", email: "alex@example.com", password: "longusername" }));
// { status: 400, body: "PASSWORD_MATCHES_USERNAME" }

struct collects the field errors in enumeration order. to.Result joins them into one message at the boundary, and the dependent password rule runs only after every field has passed. The error constructors and error-producing callbacks preserve inline literal types; choose an explicit wider type if an error must be mutable.

Running the Same Checks Against a Remote Directory

Section titled “Running the Same Checks Against a Remote Directory”

Availability cannot be checked from the values alone. Task.Validation keeps the field results and accumulates their errors while the checks run asynchronously:

import { pipe, Task } from "@pplnd/core";

type AsyncFieldError = FieldError | "USERNAME_UNAVAILABLE";

const checkUsernameAvailable = (username: string): Task.Validation<AsyncFieldError, string> =>
  Task.Validation.tryCatch(
    (signal) =>
      fetch(`/api/usernames/${encodeURIComponent(username)}`, { signal }).then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return username;
      }),
    { onError: () => "USERNAME_UNAVAILABLE" as const },
  );

const validateRegistrationAsync = (
  input: RegistrationInput,
): Task.Validation<AsyncFieldError, RegistrationInput> =>
  pipe(
    Task.Validation.from.Validation(validateRegistration(input)),
    Task.Validation.map((registration) => (_username: string) => registration),
    Task.Validation.apply(checkUsernameAvailable(input.username)),
  );

The synchronous field checks keep their own error values; only the availability lookup needs an asynchronous error channel. apply runs both sides and accumulates their errors, so awaiting the task returns one Validation with every failure from the local checks and the lookup.

Operation What it does Error or return type
make.passed(value) / make.failed(error) Wrap a value / a single error. Passed<A> / Failed<E>
make.failedAll(errors) Wrap an existing non-empty error array. Failed<E>
tryCatch(operation, { onError }) Run synchronous code and map a thrown value to one error. Validation<E, A>
from.Predicate(predicate, onFalse) Check a value; a type guard also narrows it. Validation<E, A>
from.nullable(onNull) / from.Maybe(onNone) Replace a missing value with one error. Validation<E, A>
from.Result(result) Convert Ok to Passed and Err to a one-error Failed. Validation<E, A>
Operation What it does Guarantee
struct(fields) Combine a record of already-created validations. Collects errors in own-key enumeration order.
apply(argument) Apply a wrapped function to a wrapped argument. Concatenates errors from the function and argument.
Operation What it does Guarantee
map(transform) / mapError(transform) Transform a passed value / each error. The other case passes through.
tap(effect) / tapError(effect) Inspect a passed value / the complete error list. Returns the original validation when the callback returns.
recover(fallback) Run a fallback validation on failure. Receives the complete error list.
recoverUnless(isBlocked, fallback) Recover only when no error matches the predicate. A matching error keeps the original failure.
Operation What it does Return type
match({ passed, failed }) Handle both cases. The selected handler’s return value.
getOrElse(fallback) Extract success or call a no-argument fallback. Passed or fallback value.
is.passed(value) / is.failed(value) Test and narrow the variant. Boolean type guard.
to.Result(validation) Convert to a fail-fast result. An error contains the non-empty list.
to.Result(combineErrors) Convert using a function to combine the errors. A curried converter to Result.
to.Maybe(validation) Convert success or failure to presence or absence. Discards errors.
Operation What it does Return type
keyed.make(validators) Run one validator per matching input field. A validation for each field.
keyed.is.passed(fields) / keyed.is.failed(fields) Check whether all fields passed / any failed. The passed check narrows each field.
keyed.getPassed(fields) / keyed.getErrors(fields) Extract all passed values / errors grouped by failed field. Maybe of the record.

For field-specific messages, keyed.make retains each field’s result. Pass that record to struct when a single validation with a combined error list is needed. A validator can also transform its field: the input type comes from the validator’s parameter and the retained result from its return value.

struct combines results that JavaScript has already evaluated; it does not schedule work or run checks concurrently. An empty record passes with an empty object. Errors retain field enumeration order and each field’s internal error order. apply likewise receives an already-created argument and appends the argument’s errors after the function’s errors.

For example, errors from both fields are retained, including multiple errors from one field:

import { Validation } from "@pplnd/core";

const registration = Validation.struct({
  username: Validation.make.failedAll(["USERNAME_REQUIRED", "USERNAME_FORMAT"]),
  email: Validation.make.failed("INVALID_EMAIL"),
});
// Failed(["USERNAME_REQUIRED", "USERNAME_FORMAT", "INVALID_EMAIL"])

Independent checks can all report failures. A check that needs another check’s output cannot run until that output exists; convert to Result for dependent fail-fast steps. Validation has no chain or assign operation.

tryCatch catches synchronous throws from its operation and maps the thrown value to one error. A throw from onError, a predicate, a mapper, a handler, or any other callback escapes. A promise returned from tryCatch is a Passed value; a later rejection is not caught. Use Task.Validation for independent asynchronous validations.

When a form has to decide when errors become visible rather than what the errors are, keep that decision outside the validators. The per-field validation recipe shows the trigger and visibility model around Validation.keyed.

Error constructors preserve inline literal types, including Validation.make.failed("USERNAME_REQUIRED"). Inline object errors can infer readonly literal properties; supply a wider explicit type when needed. This is a TypeScript inference rule and does not freeze objects. TypeScript 5.5 can widen primitive literals returned by callbacks, so use as const when their exact error types are required.