<!-- Source: README.md -->

# @codenhub/validation

Checks that a value is what you need it to be, and gives you either the typed value or every reason it is not. Each validator is a small function you import on its own, so a package or app ships only the checks it uses. Works for a single value such as an email or a port, and for whole objects such as a form. No dependencies.

> **Experimental:** pre-1.0. The API of 0.3.0 is meant to hold for every 0.3.x release; a change that breaks callers, if one proves necessary, ships as the next minor and is listed in the [changelog](docs/changelog/index.md).
>
> **Migrating from 0.1.0:** the [0.2.0 changelog](docs/changelog/0.2.0.md) maps each changed call to its replacement. From 0.0.1, start with the [0.1.0 changelog](docs/changelog/0.1.0.md).

## Installation

```sh
pnpm add @codenhub/validation
```

## Usage

A validator is a function. Call it with any value and read the result:

```ts
import { email, englishMessages, formatIssue } from "@codenhub/validation";

const result = email()(input);

if (result.ok) {
  result.value; // string
} else {
  formatIssue(result.error.issues[0], englishMessages); // "Invalid email address"
}
```

Build objects from validators, and read the type from the validator so there is only one place to change:

```ts
import { email, number, object, optional, string, type Infer } from "@codenhub/validation";

const signup = object({
  name: string({ trim: true, min: 2 }),
  email: email(),
  age: optional(number({ int: true, min: 18 })),
});

type Signup = Infer<typeof signup>; // { name: string; email: string; age?: number | undefined }

const result = signup(requestBody);
```

Invalid input never throws, with one exception noted below. Every problem a validator reports is in `result.error.issues`, each with a `code`, a `path` to the offending value and `params` describing the failure. Turn an issue into text with `formatIssue`, or group them by field for a form with `flatten`, passing `englishMessages` or a map of your own.

Every validator takes options, then checks, which are rules for the rarer cases, and a `message` option for a sentence of its own:

```ts
import { check, object, pattern, string } from "@codenhub/validation";

const account = object(
  {
    handle: string({ trim: true, min: 3, message: "At least 3 characters" }, pattern(/^[a-z0-9_]+$/)),
    password: string({ min: 12 }),
    confirm: string(),
  },
  check((data) => data.password === data.confirm, { path: ["confirm"], message: "Passwords must match" }),
);
```

Write a rule of your own with `check`, including one that needs to `await` something, a format with `format`, and a validator for any type with `guard`; what they make behaves exactly as the built-in ones do.

When invalid input is a caller's mistake, such as an options object passed to your function, `assert(validator, input, { subject, messages })` returns the value or throws a `TypeError` naming the first problem and where it is. `objectLike` validates class instances and other objects that are not plain, which `object` rejects.

## Documentation

- [Documentation overview](docs/index.md)
- [API reference](docs/reference/index.md)
- [Validator reference](docs/validators.md)
- [Custom validators](docs/custom-validators.md)
- [Issues and messages](docs/errors.md)
- [Coercion](docs/coercion.md)
- [Standard Schema](docs/standard-schema.md)
- [Changelog](docs/changelog/index.md)

## Requirements

- Node.js 24 or newer, or a current browser, worker or edge runtime. The code relies on `Object.hasOwn`, and `url()`, `email()`, `domain()` and `hostname()` on `URL.canParse`, which a runtime without it, such as Safari before 17, lacks, so they throw there. `ip()` and `cidr()` use the `URL` parser too.
- ESM-aware package resolution. The package is ESM only and marked `sideEffects: false`, so a bundler removes the validators you do not import. CommonJS code can `require()` it, since every supported Node.js version loads ES modules that way.
- TypeScript 5.0 or newer, for the types. The declarations use `const` type parameters, which TypeScript 4.9 cannot read.

Runtime code uses only standard JavaScript and the standard `URL` global, and nothing specific to a browser or to Node.js, so it runs in the browser, on the server and in workers.

## Notes

- A validator returns `{ ok: true, value }` or `{ ok: false, error }`. Bad input is never thrown, except by code the input carries, below; a bad option, such as `string({ min: -1 })`, throws when the validator is created.
- Issues never contain an input value, and messages name types (`Expected number, received string`) instead of echoing values. Text for an issue is built only when you ask for it with `formatIssue`, from a message map you pass: `englishMessages` for the built-in English, which is a separate import so a program that words its own issues does not bundle it, or your own to reword or localize. Keys are another matter: a path leads through the input's own keys, and a strict `object` names each key it does not recognize.
- Rules never rewrite the value unless you ask: `trim`, `case` and `clamp` are the options that do. Formats with several spellings are the exception, and produce one: `email()` and `url()` produce what the URL parser reads, such as `https://example.com/admin` for `https://Example.com/public/../admin`, so a check made later on the value sees what a request or a mail server will, `domain()` produces lowercase ASCII with internationalized labels in punycode, and `ip()`, `cidr()`, `hostname()`, `uuid()`, `ulid()`, `phone()`, `mac()` and `creditCard()` produce a canonical spelling.
- Validation is synchronous until a rule returns a promise. The types then say the result must be awaited, and the compiler keeps you from reading it as if it were ready.
- `email()` and `url()` accept public host names only: not `localhost`, IP addresses, or special-use names such as `db.internal`, `printer.local` and `nas.home`. Whether the top-level domain exists is not checked. A validator for the host replaces that rule: `url({ host: hostname() })` accepts any hostname and `url({ host: union([domain(), ip()]) })` any public domain or IP address, of any range. Neither resolves the name, so a public name can still point at a private address.
- Exceptions thrown by your own callbacks propagate. They are bugs, not invalid input. So do exceptions from code inside the input itself: a getter or a `Proxy` trap that throws while a validator reads the value, except under `objectLike`, which reports a property it could not read as an issue. Data parsed from JSON holds neither.
- A recursive `lazy` validator stops at `maxDepth` levels, 128 by default, and fails with `too_big` instead of exhausting the stack, so deeply nested or cyclic input is reported like any other bad input, as long as the recursion is synchronous. An asynchronous rule inside a recursive schema starts each level it awaits before from a fresh stack, which `maxDepth` does not count, but `maxCalls` does: each level is a `lazy` call, and the count lasts across the awaits of this package, such as an asynchronous `check`'s, so such a schema, given a cyclic object, stops too. An await inside a validator you write yourself is not seen, and what it calls after it is counted afresh, so put asynchronous work in a `check`, or bound such a validator yourself. `lazy` stops after `maxCalls` calls in one validation, 10,000 by default, as a backstop for work that grows faster than the input. A `lazy` gives the result it found for an object at a path again when the same object is reached there again in one validation, so the options of a recursive `union` of objects share the children's result and the work grows with the input; a schema that makes a new object at every level, such as with a `transform` that copies its value, cannot share it, and could otherwise run for hours on a few hundred bytes. At that default a validation it stops holds about 10 MB of issues for each `lazy`. Raise it for trusted recursive data with more nodes, or use `tagged` for objects told apart by a property. Nothing else limits how much input is checked: a large input that passes is validated in full, and asynchronous rules of every item start at once. What a failing input reports is limited, as the last note here says. Cap the size of untrusted input, and give `array`, `set`, `map`, `record` and a `tuple` with `rest` a `max`, before validating it. A byte limit is not enough for a value received by structured clone, such as `event.data` from `postMessage`, a worker or Electron IPC: it can hold a sparse array whose length costs nothing to send, or the same array at many places, so a message of a few bytes holds millions of items. Only `max` bounds those.
- A collection stops once its items have reported 1,000 issues, and adds one `too_big` issue that says so, so a long list of bad items costs a bounded amount of memory and time instead of about a thousand times its size. Input that passes is still validated in full: cap the size of untrusted input and give collections a `max`.

## License

Licensed under Apache-2.0.

<!-- Source: docs/index.md -->

# Validate values and shapes

`@codenhub/validation` answers one question: is this value what I said it should be? You hand a value to a validator and get back either that value, typed, or a complete list of what is wrong with it. It works the same for one value, such as an email address or a port number, and for a whole object such as a form or a request body.

This matters wherever data crosses a boundary you do not control: a request, a form, a query string, an environment variable, a file, a message from another service. TypeScript cannot check those at runtime, so the type you wrote for them is a promise nothing enforces until a validator does.

## Installation

```sh
pnpm add @codenhub/validation
```

The package is ESM only and has no dependencies. Its types need TypeScript 5.0 or newer. Everything is imported by name from `@codenhub/validation`; the only other export is `@codenhub/validation/package.json`, for tools that read the version.

## A validator is a function

Every validator takes any input and returns a result. That is the whole idea, and it has two consequences worth knowing up front.

You create a validator by calling a function, with options if you want them, and then you call the validator with the value:

```ts
import { number, string } from "@codenhub/validation";

const port = number({ int: true, min: 1, max: 65535 });

port(8080); // { ok: true, value: 8080 }
port(70000); // { ok: false, error: { issues: [...] } }

string({ min: 3 })("ab"); // { ok: false, ... }
```

Every validator takes its arguments in the same order: its own, such as the shape of an object, then options, then checks, which are rules for the rarer cases, such as `string({ min: 3 }, startsWith("a"))`. Options and checks can both be left out.

The first line of that example runs once, when the module loads, and produces a function you can reuse for every value. When you only need to check a single value once, call both at once: `number({ int: true })(input)`.

Because a validator is only a function, you can write your own with no helper at all, and you can pass any validator wherever another expects one. [Custom validators](docs/custom-validators.md) shows how.

## Reading the result

A result is one of two plain objects, so you branch on `ok`:

```ts
import { email, englishMessages, formatIssue } from "@codenhub/validation";

const result = email()(input);

if (result.ok) {
  save(result.value); // typed as string
} else {
  for (const issue of result.error.issues) {
    console.log(issue.path, formatIssue(issue, englishMessages));
  }
}
```

A validator never throws for invalid input, because invalid input is an expected outcome and not a bug. The one exception is input that runs code of its own while it is read, a getter or a `Proxy` trap that throws: that exception propagates, as one from your own callback does, except in `objectLike`, which reports a property it could not read as an issue. Data parsed from JSON has no such code. If you would rather stop the program, that is one line: `if (!result.ok) throw new Error(...)`.

`result.value` is not necessarily the input you passed in. It is the value after the validator has run, with any clean-up you asked for applied (`trim`, `case`, `clamp`) and, for an object, unlisted properties dropped. A format with several spellings produces one: `email` and `url` produce what the URL parser reads, such as a lowercase domain, and `phone` produces `+` and the digits, so a check made later on the value sees what a mail server or a request will, and one value is one string. The input is never modified.

When something is wrong, the validator reports every problem it can find and not just the first, so a form can show all its errors at once. Each issue has a stable `code` to branch on, the `path` to the offending value, and `params` describing what was wrong. Give a validator a `message` option for a sentence of its own, as in `string({ min: 2, message: "Enter your full name" })`, or word every issue at once with a message map. [Issues and messages](docs/errors.md) has the details.

## Objects

`object` takes a shape, an object whose values are validators, and returns a validator for objects of that shape:

```ts
import { email, number, object, optional, string, type Infer } from "@codenhub/validation";

const signup = object({
  username: string({ trim: true, min: 3, max: 30 }),
  email: email(),
  age: optional(number({ int: true, min: 18 })),
});

export type Signup = Infer<typeof signup>;
```

`Infer` reads the TypeScript type a validator produces, so the validator is the single source of truth and there is no separate interface to keep in sync. Here `Signup` has a required `username` and `email` and an optional `age`.

Issue paths lead down to the offending value, so a problem with `email` has the path `["email"]` and one inside a list has segments such as `["addresses", 0, "street"]`.

`object` accepts plain objects only: created by `{}`, `Object.create(null)` or `JSON.parse`, in this realm or another such as an iframe. Arrays, class instances, `Map`s and `null` are rejected. Properties the shape does not list are dropped from the output by default; the [validator reference](docs/validators.md#objects) shows how to reject them or keep them.

## Combining validators

A few functions combine validators into new ones:

- `optional(validator)`, `nullable(validator)` and `nullish(validator)` accept `undefined`, `null` or both as well, and `optional(validator, value)` replaces a missing value with a default.
- `pipe(a, b, c)` runs validators in order, feeding each the value the previous one produced. This is how you clean a string before checking a format: `pipe(string({ trim: true, case: "lower" }), email())`.
- `check(test, issue)` adds a rule the validator cannot express, such as two fields having to match, given to the validator after its options, and `transform(validator, convert)` changes the value into another.
- `array`, `tuple`, `record`, `set` and `map` validate collections, and `union`, `tagged` and `intersection` choose between or merge validators.
- `url` and `email` take validators for their parts, such as `url({ host: hostname() })` to accept local hosts, and `searchParams` reads a query string into typed values.

The [validator reference](docs/validators.md) covers every one.

## Sync and async

A validator is synchronous unless something it runs is asynchronous. Checking a username against a database is asynchronous; checking its length is not. You do not pick a mode: you write the rule, and the types follow. A validator that contains an async rule is an `AsyncValidator`, its result must be `await`ed, and the compiler stops you from reading it as if it were ready. Everything else stays synchronous and pays nothing. [Custom validators](docs/custom-validators.md#asynchronous-rules) has an example.

## Testing whether a value passes

When you only need a yes or no, or a type guard, use `is`:

```ts
import { is, number } from "@codenhub/validation";

const isPort = (input: unknown): input is number => is(number({ int: true, min: 1, max: 65535 }), input);
```

`is` returns a `boolean` and does not narrow the input, since a validator that trims, coerces or transforms produces another value than it was given. Where the validator keeps the value as it is, write the guard yourself, as above; otherwise read `result.value` from calling the validator. `is` accepts synchronous validators only.

## Throwing for invalid configuration

A validator reports invalid input in its result instead of throwing, and only code the input carries, a getter or a `Proxy` trap that throws while it is read, propagates an exception. When invalid input is a mistake of the caller, such as an options object passed to your function, `assert` returns the value or throws a `TypeError` that names the problem and where it is:

```ts
import { assert, englishMessages, number, object } from "@codenhub/validation";

const options = object({ port: number({ int: true, min: 1, max: 65535 }) });

assert(options, { port: 8080 }, { subject: "createServer:", messages: englishMessages }); // { port: 8080 }
assert(options, { port: 0 }, { subject: "createServer:", messages: englishMessages });
// TypeError: createServer: port: Must be at least 1
```

[Issues and messages](docs/errors.md#throwing-for-invalid-configuration) covers the message, and how to bundle only the wording you need.

## Next steps

- [Validator reference](docs/validators.md): every validator and combinator, with its options and the issue it reports.
- [Custom validators](docs/custom-validators.md): add rules with `check`, build validators with `format` and `guard`, and validate asynchronously.
- [Coercion](docs/coercion.md): validate text input such as environment variables, query strings and form fields by converting it.
- [Standard Schema](docs/standard-schema.md): use a validator wherever a library accepts a Standard Schema.
- [Issues and messages](docs/errors.md): the shape of an issue, the built-in codes, message text, localization and form errors.
- [API reference](docs/reference/index.md): every export with its signature and documentation, generated from the source.
- [Changelog](docs/changelog/index.md): release notes, and the migrations from 0.1.0 and from 0.0.1.

<!-- Source: docs/coercion.md -->

# Coercion

Data that arrives from outside a program is often text even when it means something else: every environment variable, query-string parameter and form field is a string, whether it stands for a port number, a yes-or-no flag or a date. A strict validator such as `number()` rejects `"8080"` because it is a string, which is correct and no help. A coercing validator converts the text first and then validates the result, so one call gives you a typed value or the reasons it could not be one.

```ts
import { coerceNumber } from "@codenhub/validation";

const port = coerceNumber({ int: true, min: 1, max: 65535 });

port("8080"); // { ok: true, value: 8080 }
port("0"); // { ok: false, ... }, code "too_small"
port("eighty"); // { ok: false, ... }, code "invalid_type"
```

## The validators

There is one for each type that commonly arrives as text. Each takes exactly the options of its strict counterpart, applies them to the converted value, and throws for a bad option when it is created, so `coerceNumber({ min: Number.NaN })` fails at once just as `number({ min: Number.NaN })` does. They take checks and a `message` option too, and the message also words input that cannot be converted.

| Validator                | Produces | Accepts                                                                                                                                                  | Strict version |
| ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `coerceString(options?)` | string   | Strings, finite numbers, bigints and booleans, converted to their string form.                                                                           | `string`       |
| `coerceNumber(options?)` | number   | Numbers, and strings holding a decimal number such as `"42"`, `" 3.5 "`, `"-1"`, `".5"`, `"5."`, a dot with no digits on one side being how people type. | `number`       |
| `coerceBoolean()`        | boolean  | Booleans, the numbers `1` and `0`, and the words `true`, `false`, `yes`, `no`, `on`, `off`, `1` and `0` in any letter case.                              | `boolean`      |
| `coerceBigint(options?)` | bigint   | Bigints, safe integers, and strings holding a decimal integer.                                                                                           | `bigint`       |
| `coerceDate(options?)`   | `Date`   | `Date`s, whole timestamps in milliseconds, and ISO 8601 strings such as `2026-09-28` or `2026-09-28T14:30:00Z`.                                          | `date`         |

Surrounding whitespace is ignored in the text `coerceNumber`, `coerceBigint`, `coerceBoolean` and `coerceDate` accept. It is whitespace as JavaScript's `trim` reads it, which includes a no-break space and a byte order mark, so text pasted with either is read too. `coerceString` passes a string through as it is; trim it with `pipe(coerceString(), string({ trim: true }))`.

## What is not converted

Coercion is deliberately narrow, because a conversion that guesses turns a bug into a plausible-looking value. These fail, with the reason in the issue:

- **Strings:** `NaN` and `Infinity`, which a failed numeric conversion upstream leaves behind, and `null`, `undefined`, objects, arrays, functions and symbols.
- **Numbers:** empty strings, `"1e3"`, `"0x10"`, `"1,5"`, `"Infinity"` and `"NaN"`, text holding a whole number beyond `Number.MAX_SAFE_INTEGER`, which would silently lose digits (use `coerceBigint` for those), or a nonzero decimal too small for a double, which would silently be `0`, and booleans, `null`, objects and arrays. `Number(true)` is `1` and `Number("")` is `0`; reading a flag or a missing value as a count is how bugs hide.
- **Booleans:** every other word. A typo such as `"ture"` is an error, not `false`.
- **Bigints:** fractions, numbers beyond `Number.MAX_SAFE_INTEGER`, which have already lost precision, and any other text.
- **Dates:** free-form text such as `"yesterday"` or `"09/28/2026"`, whose reading depends on the runtime, and days that do not exist such as `2026-02-30`.

A value that cannot be converted fails with `invalid_type`, and its `params` are `{ expected, received, coerced: true }`. The default message names the received and expected types, as in "Cannot convert string to a number", and the value itself is never in the issue. A value that converts but then breaks a constraint fails with that constraint's own code, as for the strict validator. A value already of the type is handed to the strict validator as it is, so `NaN` or an infinity given to `coerceNumber`, or an invalid `Date` given to `coerceDate`, fails as `number()` or `date()` fails it, with `invalid_type` and no `coerced`.

A fraction of a second beyond milliseconds is cut, not rounded, the same way on every runtime, and a timestamp with a fraction of a millisecond is rejected rather than cut. Leap seconds (`23:59:60`) are rejected, since a `Date` cannot hold one. The text `"-0"` is read as `0`, while the number `-0` is passed through as it is, as `number` does, and a decimal is read as the nearest double, so `"0.30000000000000004999"` is `0.30000000000000004`.

Date text is read in these ISO 8601 forms: a date `YYYY-MM-DD` alone, or followed by `T` or a space, a time `HH:MM` or `HH:MM:SS`, an optional fraction of a second after the seconds, and an optional zone, which is `Z` or an offset written `+HH`, `+HHMM` or `+HH:MM` (or with `-`). This is wider than `datetime()`, which accepts only the `T` separator and `Z` or, with `offset`, `+HH:MM`, because a converter reads what programs and people commonly write while a format checks one exact spelling. A date alone is read as midnight UTC. A date-time without a zone fails: `2026-09-28T14:30` names a time on some clock and not a moment, and reading it in any one zone would give, for everyone elsewhere, a moment hours off, without a word. An HTML `datetime-local` input sends exactly that, without seconds when they are zero, so add the user's offset to its value before converting it. Text you know is written in UTC is read so with `coerceDate({ zoneless: "utc" })`. Either way, the result does not depend on the timezone of the machine.

## Reading a whole environment

Combine coercion with `object` and `optional` with a default to turn an environment, a query string or a form into typed configuration, and to report every problem in it at once:

```ts
import { coerceBoolean, coerceNumber, object, optional, string, transform } from "@codenhub/validation";

const env = object({
  PORT: coerceNumber({ int: true, min: 1, max: 65535 }),
  DEBUG: optional(coerceBoolean(), false),
  RETRIES: optional(coerceNumber({ int: true, min: 0 }), 3),
  ORIGINS: transform(string(), (text) => text.split(",")),
});

const result = env({ PORT: "8080", ORIGINS: "https://a.example,https://b.example" });
// { ok: true, value: { PORT: 8080, DEBUG: false, RETRIES: 3, ORIGINS: [...] } }
```

In Node.js, pass a copy of the environment: `env({ ...process.env })`. `process.env` itself is not a plain object, since its prototype is not `Object.prototype`, and `object` rejects it as it rejects a class instance, with a message that says to copy it. A missing variable is `undefined`, which a default replaces and everything else rejects. For a query string, [`searchParams`](docs/validators.md#searchparams) reads the text into an object first, and gives a repeated key, as in `?id=1&id=2`, as an array when you ask for it with `repeated: true`, so validate it with `array(coerceNumber())`.

To clean text before converting it, put a strict validator in front with `pipe`: `pipe(string({ trim: true }), coerceNumber())`. For text that holds JSON, use `json`, which parses it and validates the result.

<!-- Source: docs/custom-validators.md -->

# Custom validators

The built-in validators cover common cases. For everything else there are two things to write, and neither needs registration:

- A **check** adds a rule to a validator you already have, such as two fields having to match or a name not being taken.
- A **validator** checks a type or a format of your own, such as a `File` or a postcode.

The helpers `check`, `format` and `guard` build both on the same internals as the built-in ones, so what you build behaves exactly as they do: the same issue shapes, a `message` option, and checks of its own.

## Adding a rule with `check`

`check(test, issue?)` makes a check from a test of a typed value. Give it to a validator after its options:

```ts
import { check, number, string } from "@codenhub/validation";

const even = check((n: number) => n % 2 === 0, "Must be even");

number({ int: true }, even);
string(
  { trim: true },
  check((name) => name !== "admin", { code: "reserved_name", message: "That name is reserved" }),
);
```

The test receives the value the validator produced, after its clean-up, and returns `true` when the value is acceptable. The second argument is the issue to report when it returns `false`: a string is its message, a function of the issue words it when it is reported, as the message of a built-in check does, and must return text, or the validator throws a `TypeError` when it reports the issue, and an object can set a `code`, `path`, `params` and `message`. A `path` is a list of keys and indexes; anything else, such as text or a symbol, throws a `TypeError` when the check is made. Without it the issue has code `custom` at the value's own location. An issue without a message of its own is worded by the `message` option of the validator the check is given to, when it has one. Otherwise `formatIssue` has only its code, `custom`, which `englishMessages` has no wording for, since a failed check carries nothing to word, and says "Invalid value": give a check a message wherever people read what it reports.

A check runs once the value has its type and has passed the validator's own options, so `string({ max: 30 }, check(isFree))` never asks about a name longer than 30 characters. For an object that means once every property has passed, so a check can compare them and rely on their types. Point the issue at the field the user should fix with `path`:

```ts
import { check, object, string } from "@codenhub/validation";

const signup = object(
  { password: string({ min: 8 }), confirm: string() },
  check((data) => data.password === data.confirm, { code: "mismatch", path: ["confirm"], message: "Passwords must match" }),
);
```

`data` needs no annotation: its type comes from the object. A check of the wrong type, such as a number check given to `string`, is a compile error.

A check can also be written by hand, as a function that returns nothing for a value it accepts or the issues it found. Each issue it returns is an object, as `fail()` makes one: anything else in the list, such as `null`, throws a `TypeError` when the check reports it, and an issue without a `code` has code `custom`. Its `code` and `message` are text and its `path` a list of keys and indexes, as the `ValidationIssue` type says; a `path` that is not, such as `"confirm"`, throws a `TypeError` too, and building issues with `fail()` has the rest checked when they are made. Type it as `Check<T>`, which is how it reports several issues at once or chooses a path per failure:

```ts
import { string, type Check } from "@codenhub/validation";

const strongPassword: Check<string> = (password) => {
  const issues = [];
  if (!/\d/.test(password)) {
    issues.push({ code: "missing_digit", path: [] });
  }
  if (!/[A-Z]/.test(password)) {
    issues.push({ code: "missing_uppercase", path: [] });
  }
  return issues.length > 0 ? issues : undefined;
};

const password = string({ min: 12 }, strongPassword);
```

## Making a validator

### A format with `format`

`format(name, test)` makes the factory of a string format, which behaves as `email()` or `uuid()` do: a non-string fails with `invalid_type`, and a string the test rejects with `invalid_format` and `params.format` set to `name`. The string is produced as written. The test must answer at once: one that returns a promise throws a `TypeError` when the validator runs, since a pending answer would pass every value, and a rule that waits is a check, [below](#asynchronous-rules).

```ts
import { format } from "@codenhub/validation";

export const postcode = format("postcode", (text) => /^\d{5}(?:-\d{4})?$/.test(text));

postcode()("12345"); // { ok: true, value: "12345" }
postcode({ message: "Enter a ZIP code" })("abc"); // { ok: false, ... }
```

`name` is part of the format's contract: callers branch on it and message maps word it, so choose it once.

### A type with `guard`

`guard(expected, typeGuard)` makes the factory of a validator for any type from a type guard. A value the guard rejects fails with `invalid_type` and `params.expected` set to `expected`. The guard must answer at once, as the test of `format` must:

```ts
import { guard, object } from "@codenhub/validation";

export const file = guard("File", (input): input is File => input instanceof File);

const upload = object({ avatar: file({ message: "Choose an image" }) });
```

### A validator with options of its own

A validator with options is a function that checks its options and returns what a builder or a built-in makes. Check the options when it is called, not when the validator runs, so a mistake in a schema is found at once:

```ts
import { check, string, type Validator } from "@codenhub/validation";

export function sku(prefix: string): Validator<string> {
  if (!/^[A-Z]{2,4}$/.test(prefix)) {
    throw new RangeError(`prefix must be 2 to 4 capital letters, received "${prefix}"`);
  }
  return string(check((text) => new RegExp(`^${prefix}-\\d{6}$`).test(text), { code: "invalid_sku", params: { prefix } }));
}
```

### By hand

A validator is any function that takes an input and returns a result. Return `pass(value)` when the input is acceptable and `fail(...)` when it is not:

```ts
import { fail, pass, type Validator } from "@codenhub/validation";

const even: Validator<number> = (input) => (typeof input === "number" && input % 2 === 0 ? pass(input) : fail({ code: "not_even" }));
```

The parameter is `unknown` because a validator exists to check data you do not trust yet. The `Validator<number>` annotation makes the compiler check your function against the contract and tells `Infer` what it produces. A hand-written validator works everywhere a built-in does, inside `object`, `optional` and `pipe`, but has no `message` option or checks unless you write them, which is what the builders are for.

`fail` takes one or more issues, and throws a `TypeError` for none, since a failure with no issue says nothing, and for a `path` that is not a list, such as `"confirm"` where `["confirm"]` was meant, which would be split into one segment per letter. Each can set:

| Field     | Meaning                                                                                                                                    |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `code`    | A stable string callers branch on. Defaults to `"custom"`. Pick your own, such as `"username_taken"`.                                      |
| `path`    | Where the problem is, relative to the value you were given. Defaults to the value itself. A parent adds its own segments in front.         |
| `params`  | Facts about the failure, for building a message or for callers to inspect. Never put the input in here: an issue is something callers log. |
| `message` | Fixed message text. Without it, [`formatIssue`](docs/errors.md#turning-an-issue-into-text) builds text from the `code` and `params`.            |

## Converting a value

`transform(validator, convert)` changes the value a validator produced into another, and cannot reject it. A conversion that can fail, such as reading a day as a `Date`, is a validator of its own placed after the first with `pipe`:

```ts
import { fail, isoDate, pass, pipe, type Validator } from "@codenhub/validation";

// isoDate has already checked the text is a day such as 2026-09-28, so this reads it as UTC midnight
// the same way on every runtime. `new Date(text)` on free-form text would not.
const startOfDay: Validator<Date> = (input) => {
  const date = new Date(`${String(input)}T00:00:00Z`);
  return Number.isNaN(date.getTime()) ? fail({ code: "invalid_date" }) : pass(date);
};

const birthday = pipe(isoDate(), startOfDay);
```

For dates in general, [`coerceDate`](docs/coercion.md) already does this conversion.

## Asynchronous rules

A rule that needs to wait, such as checking a username against a database, is a check whose test returns a promise:

```ts
import { check, email, object, string } from "@codenhub/validation";

const username = string(
  { trim: true, min: 3 },
  check(async (name) => !(await isTaken(name)), { code: "username_taken" }),
);

const signup = object({ username, email: email() });

const result = await signup(input);
```

Nothing else changes. A validator given an asynchronous check becomes an `AsyncValidator`, and so does everything that contains it: `signup` above is one because `username` is. The type tells the compiler the result must be awaited, so reading `signup(input).ok` without `await` is an error you find while writing, not a bug you find in production. A hand-written check that may wait is typed `AsyncCheck<T>`; typing it `Check<T>` keeps its validators synchronous, and the compiler holds you to it.

You can also write an asynchronous validator directly:

```ts
import { fail, pass, type AsyncValidator } from "@codenhub/validation";

const existingUser: AsyncValidator<User> = async (input) => {
  const user = typeof input === "string" ? await findUser(input) : undefined;
  return user ? pass(user) : fail({ code: "unknown_user" });
};
```

What such a validator calls after its own await is a validation of its own, apart from the one that reached it, since the package cannot follow an await it did not make: a `lazy` it reaches counts its `maxCalls` afresh, so recursion through it is not bounded. Keep the await in a check, whose awaits the package makes, when the validator leads back to a recursive schema.

Things to know about asynchronous validation:

- **Always `await` the result of an `AsyncValidator`.** It may be a promise, or a plain result when the validator could answer without waiting, for instance `optional(existingUser)` given `undefined`. `await` handles both.
- **Independent properties and items run their rules concurrently.** In `signup` above, the username lookup and the email check start together, and so does the rule of every item of an array, with no limit. Give the array a `max` before validating a list you did not write, so it cannot start thousands of lookups at once.
- **Issues stay in a fixed order.** They follow the order of the shape and of the checks, not the order in which the promises finished.
- **`is` needs a synchronous validator.** It throws a `TypeError` for one that turns out to return a promise.
- **A rejected promise is a bug, not invalid input.** If your test throws or rejects, the exception propagates. Report invalid input by returning `false` or an issue.

## What you must not do

- **Do not put the input in an issue.** No `params` value, no `message` built from the input. Issues are logged, and inputs are passwords and tokens.
- **Do not throw for bad input.** Return `false` from a test, issues from a check, or `fail` from a validator. Throw only for a mistake in how a validator was set up, and do it when it is created, not when it runs.
- **Do not modify the input.** Return a new value from `pass` when a validator changes it.
- **Do not make a validator when a module loads unless the call is marked pure.** `export const postcode = format(...)` runs when the module loads, so a bundler keeps it even in a program that never uses it. That is harmless in an app, but a library of validators that wants each to be dropped when unused writes `export const postcode = /* @__PURE__ */ format(...)`.

## Testing a validator

A validator is a function, so a test calls it and looks at the result:

```ts
import { expect, it } from "vitest";

// `even` is the validator written above.
it("rejects odd numbers with the not_even code", () => {
  const result = even(3);
  expect(result.ok).toBe(false);
  expect(!result.ok && result.error.issues[0]?.code).toBe("not_even");
});
```

<!-- Source: docs/errors.md -->

# Issues and messages

When a value is not valid, the result says exactly why. This page describes what a failed result holds, the codes the built-in validators report, and how to turn that into text for a person.

## The failed result

```ts
// What a validator returned for { name: "A", email: "nope" }
const result = {
  ok: false,
  error: {
    issues: [
      { code: "too_small", path: ["name"], params: { minimum: 2, type: "string" } },
      { code: "invalid_format", path: ["email"], params: { format: "email" } },
    ],
  },
};
```

`error.issues` lists every problem the validator found, up to the [1,000 issues a collection reports](docs/validators.md#collections), in a fixed order, and is never empty. Its type says so too, so `result.error.issues[0]` is an issue and not `undefined`, even under `noUncheckedIndexedAccess`. A strict `object` reports its unrecognized keys first, then the issues of its properties in the order the shape lists them, whichever finished first.

## The issue

| Field     | Meaning                                                                                                                                     |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`    | A stable string naming the kind of failure. Branch on this, never on message text.                                                          |
| `path`    | Where the invalid value is, from the root of what you validated. Keys are strings and array indexes are numbers. Empty for the root itself. |
| `params`  | The facts behind the failure, such as the limit that was crossed. Present when there are any.                                               |
| `message` | Fixed text, when the issue was given one: by a validator's `message` option, a check's message, or a validator of your own.                 |

**An issue never contains an input value.** There is no field for it, and `params` holds type names and constraint values, never the value under test. Inputs are often passwords or tokens, and issues get logged and shown, so this is a rule and not a default. If you need the value, you already have it.

Keys are not values, and they do appear: a path leads through the keys of the input, so a `record` or `map` issue names the key it belongs to, and a strict `object` reports each key it does not recognize, in its path and in `params.key`. Do not put secrets in key names you validate. What a key spells is also text the sender chose, and `englishMessages` puts it inside the message as a quoted string literal (`Unrecognized key "…"`, with quotes and line breaks in it escaped), as `formatPath` does in the field names of `flatten`, where it is quoted when it holds `.`, `[`, `]`, `"`, a control character such as a line break, or a line separator, with those escaped, so a field name cannot break a log line. Escape both when you put them into HTML.

The same goes for what your own schema names. `literal` and `oneOf` report the values they accept, in `params` and in the English message, so do not use them to check a secret such as an API key: a failed check would print it.

A path can be formatted for display with `formatPath`:

```ts
import { formatPath } from "@codenhub/validation";

formatPath(["user", "addresses", 0, "street"]); // "user.addresses[0].street"
formatPath([0, "title"]); // "[0].title"
formatPath(["a.b"]); // '["a.b"]'
```

A key that is empty or holds `.`, `[`, `]` or `"` is quoted in brackets, so a key named `a.b` and the path `a`, `b` never format the same.

## Built-in codes

The code set is open: a custom validator reports whatever code it likes. These are the ones the built-in validators use.

| Code                   | Meaning                                                                                                                                                                                                                                     | `params`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_type`         | The value is not the type expected, or could not be converted to it.                                                                                                                                                                        | `expected` and `received`, both type names, and `coerced: true` when a coercing validator could not convert the value.                                                                                                                                                                                                                                                                                                                                                                                               |
| `invalid_format`       | A string does not have the required format.                                                                                                                                                                                                 | `format`, such as `"email"` or `"regex"`, plus what the rule needs. For a part of `url` or `email` that failed, `part` and `issues`, what the part validator found.                                                                                                                                                                                                                                                                                                                                                  |
| `invalid_value`        | The value has the right type but a value that is not allowed.                                                                                                                                                                               | `expected` for a literal, `options` for a list of values, `unreadable: true` for a property of an `objectLike` input that threw while it was read, `unique: true` for a repeat in an array or a set, `encodedSeparator: true` for an encoded `/` or `\` and `dotSegment: true` for a segment `.` or `..` followed by `;` or `%3B` in a URL path given to a `path` validator, or, for a number, `type` and `format` (`"int"`, `"safeInt"`, `"nonZero"`, `"multipleOf"`), with the step in `value` for `"multipleOf"`. |
| `too_small`            | Below a minimum: too short, too few items, or too small a number or date.                                                                                                                                                                   | `minimum`, `type`, and `inclusive` or `exact` where they apply.                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `too_big`              | Above a maximum, or nested deeper, or recursing more often, than `lazy` allows, or a collection that stopped at 1,000 issues.                                                                                                               | `maximum`, `type` (`"depth"` or `"calls"` for `lazy`, `"issues"` for a collection that stopped), and `inclusive` or `exact` where they apply.                                                                                                                                                                                                                                                                                                                                                                        |
| `unrecognized_key`     | An object has a property its shape does not list, in strict mode.                                                                                                                                                                           | `key`. The issue's path ends at the key.                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `invalid_key`          | A key of a `record` or `map` failed its key validator, or its key validator turned it into a key an earlier entry already has.                                                                                                              | `issues`: what the key validator found, with paths relative to the key, or for a repeated key one `invalid_value` issue with `unique: true`. The issue's path ends at the key, so it is not mistaken for a problem with the value there.                                                                                                                                                                                                                                                                             |
| `invalid_intersection` | Both validators of an `intersection` passed, but produced values that cannot be merged into one.                                                                                                                                            | None. The issue's path is where the two outputs differ.                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `invalid_union`        | A value matched none of the options of a `union`, or a tagged union got a missing or unknown tag.                                                                                                                                           | For `union`, `issues`: the issues each option found, in order, with paths relative to the union's value. For `tagged`, `discriminator` and `options`, and the issue's path is the tag's.                                                                                                                                                                                                                                                                                                                             |
| `custom`               | The default code of a `check` or `fail` call that names no code. `englishMessages` has no wording for it, since a failed check carries nothing to word, so `formatIssue` says "Invalid value": give a check a message where people read it. | Whatever the reporter set.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |

`received` names kinds of value the same way everywhere: `null`, `array`, `nan`, `infinity`, `date`, `invalid date` for a `Date` holding no moment, `object` for any other object, a `Map`, a `Set` or a class instance included, or the `typeof` of anything else. It never names a class, since that would take reading the prototype of the input. Where a plain object is expected (`object`, `record`, `tagged`), any other object is `non-plain object`, which `englishMessages` words "Expected a plain object; copy it first, as in { ...value }", since a copy, such as `{ ...process.env }`, is what passes.

The validator reference lists the exact code and `params` each validator reports.

## Turning an issue into text

Validators do not build message text when they fail. That keeps them small, and it means text is a choice you make where you show it. `formatIssue` builds it from a message map, and `englishMessages` is the built-in English one. Both word the issues validators return; an issue received from elsewhere, such as JSON from a server you do not control, is input like any other, and a malformed one may throw, so validate it before wording it:

```ts
import { englishMessages, formatIssue, number } from "@codenhub/validation";

const result = number({ min: 18 })(15);
if (!result.ok) {
  formatIssue(result.error.issues[0], englishMessages); // "Must be at least 18"
}
```

The English wording is a separate value you import, and `formatIssue` does not carry it. A program that words its own issues, or never shows one, does not bundle it, which is about 1.7 kB gzipped. The map is required, so forgetting it is a compile error, and a `TypeError` in JavaScript, rather than a form that says "Invalid value" for everything. A program whose issues all carry their own `message` passes `{}`.

The text comes from the first of these that exists:

1. The issue's own `message`, which a validator's [`message` option](docs/validators.md#wording-one-validator) or a check's message sets.
2. An entry for its `code` in the message map you pass as the second argument.
3. The generic "Invalid value".

Three issues carry the issues behind them in `params.issues`: `invalid_key`, `invalid_union` from `union`, and `invalid_format` for a failed part of a URL or an email address. `englishMessages` words `invalid_key` with the first issue the key validator found, as in "Invalid key: Must be at least 3 characters", so a form says why the key is wrong, except a key given twice, in a URL's query or by two keys a key validator makes the same, which is "Must be given only once", and a failed part the same way, as in "Invalid email address domain: Expected one of "company.com"", and words that issue with the map it was given, so an override of `too_small` in a spread of `englishMessages` reaches the key's message too. It words `invalid_union` with the first issue of the one option whose kind the input had, when there is exactly one: every other option found only that the input is not its type, or not one of the values a `literal` or `oneOf` accepts. So `union([literal(""), email()])` given `"nope"` says "Invalid email address". Otherwise no option is the one meant, and it says "Does not match any of the allowed types", because listing what every option expected reads worse than saying none matched; the per-option issues are in `params.issues` for a message of your own. A quoted issue that sits deeper than the value the outer issue is shown at is prefixed with its path, as in "width: Must be at least 1" for the `width` of an object option, and a list of allowed values names the first ten and how many more there are.

### Issues behind an issue

An issue held in another's `params.issues` keeps the issues behind it only when none of them carries issues of its own, so issues nest at most three deep, whatever composes them. A union's issues are its options' lists, so a union held inside another issue, as one for a property of an object option inside an outer union is, keeps its code and path without its options' issues; a part or a key whose issues are plain, such as a domain's `{ options: ["company.com"] }` or a repeated key's `{ unique: true }`, keeps them. Without the rule, a recursive schema nests issues once per level, an asynchronous one 10,000 times, past what `JSON.stringify` can write, and where the levels share a result, as the options of a recursive union do through one `lazy`, each path through the nesting writes it out again: 430 bytes of input made a result of 350 MB. Wording that reaches an issue held without its issues says what it can without them, such as "Does not match any of the allowed types" for a union. A limit of `lazy` that stopped the validation behind such an issue is never dropped: the `too_big` issue, with its `maximum` and `type`, stands in its place. `flatten` and `standard` use the same wording, so neither lists nested issues separately.

### Rewording and localizing

A message map is an object from code to text. A string is used as it is, and a function receives the issue, so it can use `params`, and the map it was found in, so it can word an issue nested in `params` with `formatIssue(nested, messages)` and the same map. To change some of the English, spread `englishMessages` and override the codes you want, since it is frozen and cannot be changed in place; to translate, write a map of your own, and every code you leave out says "Invalid value", so cover the codes your validators can report, which the tables above list:

```ts
import { englishMessages, formatIssue, type Messages } from "@codenhub/validation";

const shorter: Messages = { ...englishMessages, too_small: "Too short" };

const pt: Messages = {
  invalid_type: (issue) => `Esperado ${String(issue.params?.expected)}`,
  too_small: (issue) => `Mínimo de ${String(issue.params?.minimum)}`,
  username_taken: "Este nome de usuário já existe",
};

formatIssue(issue, shorter);
formatIssue(issue, pt);
```

A custom validator's own codes belong in the map too, or can carry a `message` on the issue.

An entry must be text or a function that returns text. Anything else, such as a translation lookup that returned `undefined` or an entry that is a group of translations, throws a `TypeError` when an issue of that code is worded, in `formatIssue`, `flatten`, `assert` and `standard` alike, rather than showing `undefined` in a form.

### The wording of one code

Each code's English wording is also an export of its own, so a program that can report only a few codes builds a map of those and bundles no other wording:

| Code                   | Export                       |
| ---------------------- | ---------------------------- |
| `invalid_type`         | `invalidTypeMessage`         |
| `invalid_format`       | `invalidFormatMessage`       |
| `invalid_value`        | `invalidValueMessage`        |
| `too_small`            | `tooSmallMessage`            |
| `too_big`              | `tooBigMessage`              |
| `unrecognized_key`     | `unrecognizedKeyMessage`     |
| `invalid_key`          | `invalidKeyMessage`          |
| `invalid_intersection` | `invalidIntersectionMessage` |
| `invalid_union`        | `invalidUnionMessage`        |

```ts
import { formatIssue, invalidTypeMessage, string, tooSmallMessage, type Messages } from "@codenhub/validation";

const messages: Messages = { invalid_type: invalidTypeMessage, too_small: tooSmallMessage };

const result = string({ min: 2 })("a");
if (!result.ok) {
  formatIssue(result.error.issues[0], messages); // "Must be at least 2 characters"
}
```

`englishMessages` is the map of all nine. A wording that quotes the issue behind another, as `invalidKeyMessage`, `invalidUnionMessage` and `invalidFormatMessage` do, words it with the map it was called with, so include the codes the quoted issue can have.

## Throwing for invalid configuration

Some input is not expected to be invalid: an options object another developer passes to your function is wrong only by mistake, and a mistake is thrown where it was made. `assert(validator, input, options?)` returns the value the validator produced, or throws a `TypeError`:

```ts
import { array, assert, invalidTypeMessage, object, string, tooSmallMessage } from "@codenhub/validation";

const config = object({ locales: array(string({ trim: true, min: 1 }), { min: 1 }) });
const messages = { invalid_type: invalidTypeMessage, too_small: tooSmallMessage };

export function createI18n(input: unknown) {
  const { locales } = assert(config, input, { subject: "[I18n]", messages });
  return locales;
}

createI18n({ locales: ["en", 1] }); // TypeError: [I18n] locales[1]: Expected string, received number
```

- The message is the `subject`, written as you gave it, then the path of the first issue and its wording. Without a `subject` it starts at the path, and an issue at the value itself has no path.
- The wording is the issue's own `message`, then the `messages` map, then "Invalid value". The map is optional here, since the path still says which option is wrong.
- The error's `cause` is the failure, so `error.cause.issues` lists the issues the validator reported, not only the first.
- It accepts synchronous validators only, and throws a `TypeError` if the validator returns a promise.

### Keeping your own wording

A package that already throws for its options has wording its users know. Give each validator its `message`, and `assert` needs no map, so no wording of this package is bundled:

```ts
import { assert, boolean, nonBlank, objectLike, optional, string } from "@codenhub/validation";

const feedback = objectLike(
  {
    message: string({ message: "must be a non-empty string." }, nonBlank()),
    isRetryable: optional(boolean({ message: "must be a boolean when provided." })),
  },
  { message: "must be an object whose fields can be read." },
);

export function register(input: unknown) {
  return assert(feedback, input, { subject: "Registry feedback" });
}

register({ message: " " }); // TypeError: Registry feedback message: must be a non-empty string.
register({ message: "Try again", isRetryable: "yes" }); // TypeError: Registry feedback isRetryable: must be a boolean when provided.
register(null); // TypeError: Registry feedback must be an object whose fields can be read.
```

- A validator's `message` words every issue it reports itself and every issue of its checks that has no message, so one sentence covers a wrong type, a bound and `nonBlank()`. Give a check its own message where the sentences differ.
- `assert` writes the path before the message, so the message does not name its field: it reads after `message:` or `locales[1]:`.
- `objectLike` accepts what a class instance or an `Error` is, and reports a getter that throws with the message of the object, where `object` would accept plain objects only and let the exception through.

Use `assert` for configuration and other arguments. For a form, a request or anything else a program expects to receive invalid, call the validator and read its result, which lists the issues instead of throwing for the first.

## Errors for a form

`flatten` groups the messages of a failed result for display: issues at the root go to `formErrors`, and the rest are keyed by their formatted path in `fieldErrors`.

```ts
import { englishMessages, flatten } from "@codenhub/validation";

const result = signup(input);
if (!result.ok) {
  const { formErrors, fieldErrors } = flatten(result.error, englishMessages);
  fieldErrors["email"]; // ["Invalid email address"]
  fieldErrors["addresses[0].street"]; // ["Must be at least 3 characters"]
}
```

It takes the same message map as `formatIssue`, which it requires too. Field keys use the notation of `formatPath`. `fieldErrors` has no prototype, so a field named `constructor` or `toString` cannot be mistaken for an inherited member and `fieldErrors["toString"]` is `undefined` when there is no such field. The price is that it has no methods: ask with `"name" in fieldErrors` or `Object.hasOwn(fieldErrors, "name")`, not `fieldErrors.hasOwnProperty("name")`.

## Reading the result

A result is plain data, so it can be logged, sent to a client or stored, and because no issue holds the input, doing so does not leak what was submitted. No issue holds a bigint, which `JSON.stringify` cannot serialize: a bigint bound or expected value is written as its decimal digits beside `type: "bigint"`. The bounds of a `date` validator are `Date`s, which `JSON.stringify` turns into ISO strings, and `englishMessages` words either the same. A `literal` or `oneOf` of `undefined`, a symbol or an infinity holds that value as it is, which JSON writes as `null` or leaves out, so word such issues before sending them.

<!-- Source: docs/standard-schema.md -->

# Standard Schema

[Standard Schema](https://standardschema.dev/) is a small shared interface that lets libraries accept a validator without depending on the one that made it. Form libraries, API frameworks and routers that support it take any conforming validator, from any library, directly. `standard` makes a validator conforming.

```ts
import { email, englishMessages, number, object, standard } from "@codenhub/validation";

const signup = standard(object({ email: email(), age: number({ int: true }) }), englishMessages);

signup["~standard"].validate({ email: "nope", age: 1.5 });
// {
//   issues: [
//     { message: "Invalid email address", path: ["email"] },
//     { message: "Must be an integer", path: ["age"] },
//   ],
// }
```

Pass `signup` to the library that asks for a Standard Schema, and it will call `~standard.validate` itself. You do not call it yourself unless you are writing that library.

## What `standard` returns

A validator that behaves exactly as the one you gave, with the `~standard` property added, so it is still an ordinary validator you can call and compose. The validator you gave is not modified, so the same one can be exposed twice, with different messages for different audiences.

`~standard.validate` returns `{ value }` on success and `{ issues }` on failure, and never both. It returns its result directly for a synchronous validator and a `Promise` for an asynchronous one, which the specification allows. That holds even for a validator of your own that returns another kind of thenable, such as a query builder's, since callers tell the two apart with `instanceof Promise` and would read anything else as a result without issues. The input type is `unknown` and the output type is what the validator produces, so a library that infers types from a Standard Schema gets them.

The package exports the `StandardSchemaV1` type so you can accept one in your own code.

## Messages

The specification requires a message on every issue, and this is the one place this package builds it, so a program that never uses `standard` never bundles the text. `standard` therefore takes the message map as its second argument, and it is required, so `standard` throws a `TypeError` when created without one: pass `englishMessages` for the built-in English, or a map of your own. Messages then come from [`formatIssue`](docs/errors.md#turning-an-issue-into-text): an issue's own `message`, then an entry for its `code` in the map, then "Invalid value".

```ts
import { englishMessages, number, standard } from "@codenhub/validation";

const age = standard(number({ min: 18 }), {
  ...englishMessages,
  too_small: (issue) => `Você precisa ter pelo menos ${String(issue.params?.minimum)} anos`,
});
```

Each issue's `path` is the issue's own path, as an array of strings and numbers.

## A note on functions

A validator here is a function, and `standard` attaches `~standard` to a function. The specification allows any object, and libraries that follow it read the property, so this works. A library that insists on `typeof schema === "object"` before looking would not recognize it; if you meet one, wrap the validator in an object of your own: `{ "~standard": standard(validator, englishMessages)["~standard"] }`.

<!-- Source: docs/validators.md -->

# Validator reference

Every validator here is created by calling a function and is then called with the value to check. Every one returns `{ ok: true, value }` or `{ ok: false, error: { issues } }`, and never throws for invalid input, unless the input runs code of its own, such as a getter or a `Proxy` trap that throws while it is read, whose exception propagates; [`objectLike`](#objectlike) is the one validator that reports it as an issue instead. The code and `params` each failure reports are listed with the validator; [Issues and messages](docs/errors.md) explains what they mean and how to turn them into text.

Every validator takes its arguments in the same order: its own, such as the shape of `object` or the item of `array`, then an options object, then any number of [checks](#checks). The options can be left out when there are checks: `string(startsWith("a"))`. Every validator that reports an issue of its own takes a `message` option, [below](#wording-one-validator).

Options are read once, when the validator is created. An option that makes no sense, such as a negative length, a `NaN` bound, or limits no value can satisfy together such as `{ min: 5, max: 2 }`, throws a `RangeError` or `TypeError` at that point, because it is a mistake in your code and not in the input. So do options that are not a plain object, such as `string(/^a/)` where `string(pattern(/^a/))` was meant, which would otherwise be ignored and accept every string, and a shape that is a list. So does a child that is not a function, such as `object({ name: undefined })` after an import that resolved to nothing: every combinator checks the validators and callbacks it is given, and names the one that is wrong. So does an option name the validator does not know, such as `string({ mni: 2 })` or `array(item, { maxx: 3 })`, with a `TypeError` naming it, since ignoring it would leave the limit it was meant to set unset and accept the input it was written to reject. TypeScript reports such a name too, but not for options passed through a variable typed loosely, read from configuration or written in JavaScript. An option 0.1.0 had is such a name, and throws `Unknown option <name>` as any other does: `string`'s `pattern`, `startsWith`, `endsWith`, `includes`, `lowercase` and `uppercase`, `number`'s `multipleOf` and `nonZero`, `array`'s `unique` and `url`'s `allowLocal`. An option given as `undefined` is no option, so a limit that may be missing is passed as it is: `string({ max: limit })`. The [0.2.0 changelog](docs/changelog/0.2.0.md) maps each to what replaced it.

## Strings

`string(options?)` accepts strings and produces a string. Anything else fails with `invalid_type`, naming the type it received.

```ts
import { string } from "@codenhub/validation";

const username = string({ trim: true, min: 3, max: 30 });

username("  ada  "); // { ok: true, value: "ada" }
username("ab"); // { ok: false, ... }, code "too_small"
```

The options, all optional:

| Option    | Meaning                                                                                                                                                                                                                                                                                                                      |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trim`    | Remove leading and trailing whitespace, as `String.prototype.trim` does, before the constraints run, and from the output. That is the Unicode white space and line terminators, so a no-break space goes, while a zero-width space (U+200B), which is not white space, stays: `string({ trim: true, min: 1 })` passes `"​"`. |
| `case`    | `"lower"` or `"upper"`: convert the string before the constraints run, and in the output, with the full Unicode mapping, the same in every locale. A conversion can change the length the constraints see: `ß` in uppercase is `SS`, and `İ` in lowercase is two code units.                                                 |
| `min`     | At least this many characters. Counted in UTF-16 code units, as `String.length` counts them, so an emoji can count as two.                                                                                                                                                                                                   |
| `max`     | At most this many characters.                                                                                                                                                                                                                                                                                                |
| `length`  | Exactly this many characters.                                                                                                                                                                                                                                                                                                |
| `message` | Wording for the issues it reports itself.                                                                                                                                                                                                                                                                                    |

Rarer rules are [checks](#checks): `pattern`, `startsWith`, `endsWith`, `includes`, `nonBlank`, which requires what `trim` with `min: 1` requires without trimming, and `lowercase` and `uppercase`, which require a case where the `case` option converts to one.

```ts
import { pattern, string } from "@codenhub/validation";

const handle = string({ trim: true, min: 3, max: 30 }, pattern(/^[a-z0-9_]+$/));
```

Clean-up (`trim`, `case`) always happens first, and every constraint and check then sees the cleaned string, so `string({ trim: true, min: 1 })` rejects a string of spaces. Every constraint that fails reports its own issue.

| Failure               | `code`         | `params`                                   |
| --------------------- | -------------- | ------------------------------------------ |
| Not a string          | `invalid_type` | `{ expected: "string", received }`         |
| Shorter than `min`    | `too_small`    | `{ minimum, type: "string" }`              |
| Longer than `max`     | `too_big`      | `{ maximum, type: "string" }`              |
| Shorter than `length` | `too_small`    | `{ minimum, exact: true, type: "string" }` |
| Longer than `length`  | `too_big`      | `{ maximum, exact: true, type: "string" }` |

## Numbers

`number(options?)` accepts finite numbers and produces a number, `-0` included as it was given, since it is a number of its own. `NaN`, the infinities and every other type fail with `invalid_type`.

```ts
import { number } from "@codenhub/validation";

const age = number({ int: true, min: 0, max: 130 });

age(42); // { ok: true, value: 42 }
age(1.5); // { ok: false, ... }, code "invalid_value"
```

The options, all optional:

| Option       | Meaning                                                                                                                                                                                                                                          |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `clamp`      | `{ min, max }`. Move a finite value into the range instead of rejecting it, before the constraints run and in the output. An infinity is still rejected, as by every `number`, since it more often means a fault upstream than a value to clamp. |
| `min`, `max` | At least or at most this value, inclusive.                                                                                                                                                                                                       |
| `gt`, `lt`   | Strictly greater than or less than this value.                                                                                                                                                                                                   |
| `int`        | A whole number.                                                                                                                                                                                                                                  |
| `safeInt`    | A whole number a double can represent exactly, that is within `Number.MAX_SAFE_INTEGER`.                                                                                                                                                         |
| `message`    | Wording for the issues it reports itself.                                                                                                                                                                                                        |

`multipleOf(step)` and `nonZero()` are [checks](#checks): `number({ min: 0 }, multipleOf(0.01))`.

Positive, negative and their "non-" variants are bounds: positive is `gt: 0`, non-negative is `min: 0`, negative is `lt: 0` and non-positive is `max: 0`. A `NaN` bound, or bounds no finite number can satisfy such as `{ min: 5, max: 2 }`, `{ gt: 1, lt: 1 }` or `{ min: Infinity }`, throws a `RangeError` rather than being ignored, and a bound that is not a number, or a `clamp` that is not a range with a number `min` and `max`, a `TypeError`. `clamp` throws a `RangeError` for a `NaN` bound, a minimum of `Infinity` or a maximum of `-Infinity`, a minimum above its maximum, or a range every value of which breaks a bound, such as `{ clamp: { min: 0, max: 10 }, min: 11 }`. Bounds that hold numbers but no whole one, such as `{ int: true, gt: 1, lt: 2 }`, are not caught when the validator is created, since checking would add to every consumer of `number`; such a validator rejects every input.

| Failure             | `code`          | `params`                                         |
| ------------------- | --------------- | ------------------------------------------------ |
| Not a finite number | `invalid_type`  | `{ expected: "number", received }`               |
| Below `min` or `gt` | `too_small`     | `{ minimum, inclusive, type: "number" }`         |
| Above `max` or `lt` | `too_big`       | `{ maximum, inclusive, type: "number" }`         |
| `int` or `safeInt`  | `invalid_value` | `{ type: "number", format: "int" \| "safeInt" }` |

## Booleans and symbols

`boolean()` accepts `true` and `false` and produces a boolean. Truthy and falsy values, and text such as `"true"`, fail with `invalid_type` and `{ expected: "boolean", received }`. `symbol()` accepts symbols and fails likewise with `expected: "symbol"`; to accept one symbol only, use `literal`.

## Bigints

`bigint(options?)` accepts bigints and produces a bigint. Numbers, including whole ones, fail with `invalid_type`. The options `min` and `max` are inclusive and `gt` and `lt` are exclusive, all bigints, and fail like the number bounds: `too_small` with `{ minimum, inclusive, type: "bigint" }`, or `too_big` with `{ maximum, inclusive, type: "bigint" }`. Positive is `gt: 0n`, non-negative is `min: 0n` and negative is `lt: 0n`.

Bounds appear in `params` as their decimal digits, `"10"` for `10n`, beside `type: "bigint"`, so an issue can be sent as JSON, which cannot hold a bigint. Read one back with `BigInt(params.minimum)`.

## Dates

`date(options?)` accepts valid `Date` objects and produces the same `Date`. An invalid date such as `new Date("nope")`, a timestamp or a string fails with `invalid_type` and `{ expected: "valid date", received }`. To check date text, use [`isoDate` or `datetime`](#formats).

The options `min` and `max` are `Date`s, both inclusive, and throw a `TypeError` when created with anything but a `Date`, such as a date string, and a `RangeError` when created with an invalid `Date` or with `min` after `max`. They are read when the validator is created, so changing the `Date` objects later has no effect. A date before `min` fails with `too_small` and `{ minimum, inclusive: true, type: "date" }`, and one after `max` with `too_big` and `{ maximum, inclusive: true, type: "date" }`, the bound being a `Date`.

## Fixed values

- `literal(value)` accepts exactly one value, compared with `===`, and produces that value with its exact type, so `literal("admin")` produces `"admin"` and not `string`. It produces the declared value, not the input, which differs only for zero: `===` matches `-0` to `0`, and `literal(0)` produces `0` either way. Any primitive works, and this is how `null` and `undefined` are validated: `literal(null)`. The one exception is `NaN`: `NaN === NaN` is false, so `literal(NaN)` could accept nothing, and it throws a `RangeError` when created.
- `oneOf(values)` accepts any one value of a list and produces their union: `oneOf(["admin", "user"])` produces `"admin" | "user"`. The values may be any primitives `literal` accepts, so `oneOf([true, false, null])` is a three-state flag. It also takes a TypeScript `enum`, and then ignores the reverse-mapping names TypeScript adds to a numeric enum, so only the numbers are values; an object written like an enum is read the same way, so `{ a: 1, "1": "a" }` holds `1` alone. It produces the listed value, so `oneOf([0])` produces `0` for `-0`. The values are copied when the validator is created. No values, a list with a hole such as `[, "a"]`, which would otherwise accept `undefined`, or a `Set` or other object of a class, which would be read as an enum with no members, throws a `TypeError`, and one holding `NaN`, which no value equals, a `RangeError`, since either is a mistake in the schema.

Both fail with `invalid_value`. `literal` reports `{ expected }` and `oneOf` reports `{ options }`, the list of accepted values. A bigint is reported as its decimal digits, so the issue can be sent as JSON: `literal(1n)` reports `{ expected: "1", type: "bigint" }`, and a `oneOf` of bigints alone adds `type: "bigint"` too. In a `oneOf` that mixes bigints with other values, a bigint option is its digits, as text, with nothing to tell it from a string.

```ts
import { literal, oneOf } from "@codenhub/validation";

const role = oneOf(["admin", "user"]);
const version = literal("v1", { message: "Only v1 is supported" });

enum Status {
  Active = "active",
  Archived = "archived",
}
const status = oneOf(Status);
```

## Any value, no value, functions and instances

- `unknown()` accepts every value and passes it through unchanged. Use it for a property you do not check, or as the base for checks on a value of no particular type: `unknown(check((value) => JSON.stringify(value) !== undefined))`.
- `never()` rejects every value with `invalid_type` and `{ expected: "never", received }`. Use it to forbid a property, or for a branch that must never match.
- `func()` accepts any function, from any realm, classes and async and generator functions included, and produces it unchanged. It fails with `invalid_type` and `{ expected: "function", received }`. Only that the value is a function can be checked, not the parameters it takes or what it returns, so name the signature you expect as the type argument, `func<(value: string) => void>()`, and it becomes the output type on trust, as a cast would; without one the output takes any arguments and returns `unknown`.
- `instanceOf(Class)` accepts instances of a class, subclasses and abstract classes included, checked with `instanceof`, so an instance from another realm such as an iframe is not recognized. `date`, `map`, `set` and `object` do not have that limit and accept values from any realm. It fails with `invalid_type` and `{ expected: "instance of Class", received }`. A target that is not a function, such as an import that resolved to nothing, throws a `TypeError` when the validator is created.

## Formats

A format is a validator for a string of a particular shape. Each is imported on its own, so you only ship the ones you use, and each takes a `message` option and checks. Most produce the string unchanged, so trim or lowercase first with `pipe` when the input may need it. A format whose meaning has several spellings produces one canonical spelling instead, so one value is one string however it was written: the table says which. For `email`, `url` and `domain`, the URL parser, not the text, decides what they name, so they produce what the parser reads.

A non-string fails with `invalid_type` and `{ expected: "string", received }`. A string that does not match fails with `invalid_format` and `{ format }`, and `format` names it as the table shows.

| Validator      | Accepts                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | Produces                                              | `format`                           |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------- |
| `email()`      | An email address with a public domain name. [Below](#email).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | The domain as the parser reads it                     | `"email"`                          |
| `url()`        | An absolute URL with an allowed protocol and a public host. [Below](#url).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | The URL as the parser writes it                       | `"url"`                            |
| `domain()`     | A public domain name: two or more labels, a last label of 2 to 63 letters or a punycode label, and no special-use name such as `localhost`, `.local`, `.internal`, `.test`, `.arpa`, `.home`, `.corp`, `.mail`, `.localdomain`, Kubernetes' `.svc` and `.cluster`, or an IDN test top-level domain such as `.テスト`. Whether the top-level domain exists is not checked, so a name under any other label passes, private ones in common use such as `nas.lan`, `a.private` or `a.intranet` included: block those with a check of your own when they matter, and remember that a public name can still resolve to a private address. Internationalized labels must be valid in every browser: a punycode label must decode to the label it spells, and a name with a right-to-left label must keep the bidi rule (RFC 5893), so `٠.com` and `1.ب` are rejected, as browsers reject them, in every runtime. An absolute name such as `example.com.` is rejected, as `hostname()` rejects it, and so is a name written with more than 759 characters, three for each one a name can have, before the parser reads it. The rule `email` and `url` apply by default. | Lowercase ASCII, internationalized labels in punycode | `"domain"`                         |
| `hostname()`   | A hostname: dot-separated labels of letters, digits and hyphens, the last not a number (all digits, or `0x` and hex digits, which the URL parser reads as an IPv4 address), and punycode (`xn--`) labels that decode to the label they spell and keep the bidi rule, as `domain()` requires. An absolute name ending in a dot, as in `example.com.`, is rejected: it names the same host as `example.com`, and a second spelling would let one host past a check that compares the value as a string, such as a list of blocked hosts.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | Lowercase                                             | `"hostname"`                       |
| `ip()`         | An IPv4 or IPv6 address. [Below](#ip).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | IPv6 in its canonical form                            | `"ip"`, `"ipv4"` or `"ipv6"`       |
| `cidr()`       | An address and a prefix length, such as `10.0.0.0/8`, the prefix at most 32 for IPv4 and 128 for IPv6. Takes `version` as `ip` does. Bits set past the prefix are accepted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | The address as `ip` produces it                       | `"cidr"`, `"cidrv4"` or `"cidrv6"` |
| `mac()`        | A MAC address: six pairs of hex digits separated by colons throughout or by hyphens throughout.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | Lowercase, with colons                                | `"mac"`                            |
| `phone()`      | An international number in E.164 form: `+`, a country code and the number, 7 to 15 digits, with optional spaces, hyphens or dots between digits and at most one group of them in parentheses, as in `+55 (11) 98765-4321`. A national trunk prefix in parentheses, as in `+44 (0)20 7946 0958`, is rejected, since it is no digit of the international number. The value is the `+` and the digits as given, not checked against the country's numbering plan, so a trunk prefix written without parentheses stays in it: `+44 020 7946 0958` produces `+4402079460958`, which no one can dial, where the number is `+442079460958`. Whether it exists is not checked.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | `+` and the digits                                    | `"phone"`                          |
| `creditCard()` | A payment card number: 12 to 19 digits passing the Luhn checksum, optionally grouped by spaces or by hyphens. Whether it was issued is not checked.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | The digits alone                                      | `"creditCard"`                     |
| `uuid()`       | A UUID of version 1 to 8, or the nil or max UUID, hyphenated, in any case. `uuid({ version: 7 })` requires one version, and then rejects the nil and max UUIDs.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | Lowercase                                             | `"uuid"`                           |
| `datetime()`   | An ISO 8601 date-time such as `2026-09-28T14:30:00Z`, on a day that exists. [Below](#datetime-and-time).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | As written                                            | `"datetime"`                       |
| `isoDate()`    | An ISO 8601 calendar date such as `2026-09-28`, on a day that exists.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | As written                                            | `"isoDate"`                        |
| `time()`       | A time of day such as `14:30`, `14:30:00` or `14:30:00.250`, without an offset. [Below](#datetime-and-time).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | As written                                            | `"time"`                           |
| `duration()`   | An ISO 8601 duration such as `P1Y2M`, `PT30M` or `P1DT12H`, with at least one component. Only the seconds may have a fraction, written with a dot, of any number of digits: stricter than ISO 8601, which lets the smallest component of any unit carry one and prefers a comma, and than `Temporal.Duration`, which lets an hour or a minute carry one and reads a comma too, so `PT0.5H` and `PT1,5S` are rejected, which both accept. Weeks may be combined with other units, as in `P1W2D`, which ISO 8601-2 and `Temporal` allow, and a negative duration such as `-P1D` is rejected. A consumer with a limit, such as the nine digits of `Temporal.Duration`, checks it with a check.                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | As written                                            | `"duration"`                       |
| `semver()`     | A Semantic Versioning 2.0.0 version such as `1.4.0-beta.2`, without a leading `v`. The specification sets no limit on a number or the length, and neither does `semver()`, where some libraries refuse a number past `Number.MAX_SAFE_INTEGER` or a version longer than 256 characters: add `string({ max: 256 })` in front with `pipe` when the value goes to one.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | As written                                            | `"semver"`                         |
| `slug()`       | Lowercase ASCII letters and digits in words joined by single hyphens, such as `hello-world-2`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | As written                                            | `"slug"`                           |
| `jwt()`        | A JSON Web Token in compact form: three base64url segments whose header and payload are JSON objects, with no key twice in any object, the header naming `alg` as text that is not empty. A key given twice, as in `{"alg":"none","alg":"HS256"}`, is rejected, since JSON parsers disagree on which one it means. The signature is not verified and the claims are not read, so a token that passes may be forged or expired, and an unsigned one, with `alg` `none` and an empty signature, passes too: verify a token with a JWT library before trusting it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | As written                                            | `"jwt"`                            |
| `hex()`        | One or more hexadecimal digits of any case.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | As written                                            | `"hex"`                            |
| `base64()`     | Standard base64 with correct padding, as an encoder writes it: no whitespace or line breaks, so base64 wrapped at 64 or 76 columns, as PEM and MIME write it, fails, and the bits a last character holds beyond the data must be zero, so `YWJ=`, which `Buffer.from` decodes as `ab`, fails too. The empty string, which encodes nothing, is rejected. `base64({ url: true })` requires the URL-safe alphabet, `-` and `_`, with the padding optional.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | As written                                            | `"base64"` or `"base64url"`        |
| `ulid()`       | A ULID, in any case.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Uppercase                                             | `"ulid"`                           |
| `nanoid()`     | A Nano ID in its default form: 21 characters of `A-Za-z0-9_-`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | As written                                            | `"nanoid"`                         |
| `cuid2()`      | A CUID2 identifier.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | As written                                            | `"cuid2"`                          |

`port()` is a format of numbers rather than text: a whole number from 1 to 65535. Port 0, which asks a system for any free port, cannot be connected to and is rejected. A non-number fails with `invalid_type`, and a number that is not a port with `invalid_format` and `{ format: "port" }`. Read a port from text with `pipe(coerceNumber(), port())`.

`isoDate()` produces a string. To get a `Date`, use `coerceDate()`, which reads the same text as midnight UTC.

### `email`

`email(options?, ...checks)` takes `allowPlus`, default `true`, which controls whether `+` is accepted before the `@`, as in `ada+news@example.com`, and the parts described [below](#parts-of-an-email-or-a-url). The value is the address as mail is delivered to it: the local part as written, and the domain as the URL parser reads it, lowercase ASCII with an internationalized label in punycode. So `Ada@München.DE` produces `Ada@xn--mnchen-3ya.de`, and every spelling the parser reads as one domain, such as fullwidth letters in `ada@ｅxample.com` or an invisible variation selector, produces the same address, so a check made later on the value, such as a list of blocked domains or a uniqueness check, sees the domain mail will reach. The local part must be ASCII: an address with other letters before the `@` (RFC 6531) is rejected. A domain written with more than 759 characters, three for each one a domain can have, is rejected before the parser reads it. The local part is limited to 64 characters and the whole address, as delivered, to 254. A domain is written with letters, marks, digits, dots and hyphens only, the ideographic, fullwidth and halfwidth full stops (`。`, `．`, `｡`) counting as dots, as `url` reads them too, and its internationalized labels must be valid in every browser, as `domain()` requires, so `ada@example.xn--zz` and `ada@٠.com` are rejected. A percent escape such as `ex%61mple.com` is URL syntax, which `url` decodes in a host, and is not part of an email domain, so `email` rejects it. Hosts that are not public domain names are rejected: `localhost`, single-label hosts, IP addresses, and special-use names that never reach a public host, which are those ending in `localhost`, `localdomain`, `local`, `internal`, `home`, `corp`, `mail`, `arpa`, `test`, `example`, `invalid`, `alt` or `onion`, or in one of the eleven IDN test top-level domains IANA reserved, such as `テスト` or `测试`. A domain ending in a dot, `ada@example.com.`, is rejected.

```ts
import { email, pipe, string } from "@codenhub/validation";

const address = pipe(string({ trim: true }), email());

address("  Ada@Example.com "); // { ok: true, value: "Ada@example.com" }
```

### `url`

`url(options?)` requires an absolute URL, so `example.com` and `//example.com` are rejected and no scheme is guessed. It rejects embedded credentials such as `https://user:password@example.com`, unless a `credentials` validator, [below](#parts-of-an-email-or-a-url), accepts them, as one for `DATABASE_URL` would; a URL without a host, such as `mailto`, never takes them. It rejects port 0, `https://example.com:0/`, which nothing can connect to, unless a `port` validator accepts it. The text is read by the standard URL parser, every check is made on what it read (the scheme, the credentials and the host), and the value is that reading, written back as the parser writes it. So a check made later on the value, such as an allowed path prefix or a list of blocked hosts, sees the URL a request will reach: `https://Example.com/public/../admin` produces `https://example.com/admin`, a host spelled with fullwidth letters or invisible characters produces the host they spell, an internationalized host is in punycode, an IPv4 host written as `0x7f.1` or `127.1` is `127.0.0.1`, the host of a scheme the parser has no rules for, such as `ssh://Example.COM`, is in lowercase, and characters such as `"` and `<` in the path are percent-encoded. That is URL encoding, not HTML escaping: others, such as `'` and `&`, are kept, so escape the value for the context you write it into, as any text, before putting it in HTML. Text holding whitespace or control characters, such as a line break, is rejected rather than cleaned, since a written URL never contains them: trim first with `pipe(string({ trim: true }), url())` when the input may have surrounding spaces. A host longer than 253 characters, the most a domain name can have, is rejected as well, whatever the host validator, and so is the host of `http`, `https` or another scheme the URL Standard has rules for that is written with more than 759 characters, three for each one a host can have, which is refused before the parser reads it, and so is an absolute host such as `example.com.`, a second spelling of `example.com` that would let one host past a list of blocked hosts, and so is a host with an internationalized label that is not valid in every browser, such as `xn--zz.com` or `٠.com`, or that Node.js cannot read again once written in punycode, such as `éxn--.com`, so a URL that passes is read the same in every runtime. A host validator replaces the rule that the host is public, not that one: for `http`, `https` and the other schemes the URL Standard has rules for, it is given only a host of lowercase letters, digits, `.`, `-` and `_`, an IPv4 address, or an IPv6 address, and `http://a*b.com`, `http://a%20.com` and `http://[::01.2.3.4]`, which runtimes read differently, are rejected whatever it accepts. A host that only looks like another, such as `exаmple.com` with a Cyrillic `а`, is a host of its own, and passes in its punycode form, `xn--exmple-4nf.com`: compare the value, never the text, against a list of hosts. The options, besides the parts described [below](#parts-of-an-email-or-a-url), are:

| Option      | Meaning                                                                                                                                                                                                                                                                                                                           |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `protocols` | Accepted protocols without the colon, in any letter case. Default `["http", "https"]`. The list is copied when the validator is created, and a protocol that is not a scheme name, such as `"https:"`, throws a `TypeError` then, since it would match nothing. So do `javascript`, `vbscript` and `data`, whose URLs run script. |

```ts
import { hostname, url } from "@codenhub/validation";

url()("https://example.com/a?b=1"); // ok
url()("http://localhost:3000"); // fails: not a public host
url({ host: hostname() })("http://localhost:3000"); // ok
url({ protocols: ["ftp"] })("ftp://example.com"); // ok
```

Three schemes have no host, and each is checked by its own rules when listed in `protocols`, even when the URL is written with one, as in `mailto://example.com`, which names no recipient:

| Scheme   | Accepted when                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mailto` | It names at least one recipient, and every recipient, in the path and in `to`, `cc` or `bcc`, is an address `email()` accepts, on a public domain whatever `host` is, since `host` checks a URL's host and a mailto has none. Each recipient is produced as `email()` produces it, with its local part escaped where a mailto needs it, so `mailto:Ada@EXAMPLE.com` produces `mailto:Ada@example.com` and `mailto:ada@exa%6dple.com` produces `mailto:ada@example.com`; field names are produced in lowercase. The query may hold only `to`, `cc`, `bcc`, `subject` and `body`, each as `name=value`: any other field, such as `from` or `reply-to`, fails. `subject` and `body` hold what RFC 6068 allows, letters, digits, `-._~!$'()*+,;:@` and `%` escapes, so a line break is `%0D%0A`, and also `/` and `?`, which a URL query allows, so `body=https://example.com/a?b` passes. `&`, `=`, `#` and a malformed escape such as `%zz` fail. A line break, `%0D` or `%0A`, is allowed in `body` only: in `subject` it would end the header line, so a mail program that writes it into the message unescaped would read what follows, such as `Bcc: …`, as another header. `subject` refuses every other encoded control character too, such as `%00` or a tab, and the line and paragraph separators, `%E2%80%A8` and `%E2%80%A9`, since a subject is one line of text. `subject` and `body` may each appear once, while `to`, `cc` and `bcc` may repeat, since each adds recipients. They are produced as written. |
| `tel`    | It is a global number: `+`, digits with `-`, `.`, `(` or `)` between them, then optional `;name=value` parameters whose value holds only the characters RFC 3966 allows (letters, digits, `-_.!~*'()[]/:&+$` and `%` escapes), so never `"` or `<`. A local number with `phone-context` is rejected.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `urn`    | It follows RFC 8141: a namespace of 2 to 32 letters, digits and inner hyphens, a colon, and a non-empty name.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |

Any other scheme without a host, such as `file:///etc/passwd` or `about:blank`, is rejected even when listed and whatever `host` is, so listing a protocol never lets a hostless URL through unchecked. Any other listed scheme written with a host, such as `file://server/share` or `ftp://example.com`, gets the host checks every URL does.

`javascript`, `vbscript` and `data` cannot be listed at all: `url` throws a `TypeError` when created with one. A host does not make them safe, since `javascript://example.com/%0aalert(1)` has a public host and still runs as script when followed.

```ts
url({ protocols: ["mailto"] })("mailto:ada@example.com?cc=bob@example.org"); // ok
url({ protocols: ["mailto"] })("mailto:ada@localhost"); // fails: not a public host
url({ protocols: ["tel"] })("tel:+1-201-555-0123"); // ok
url({ protocols: ["urn"] })("urn:isbn:0451450523"); // ok
```

### `ip`

`ip(options?)` takes `version`, `"v4"` or `"v6"`, to accept one address family only, and produces an IPv6 address in its canonical form: lowercase, with the longest run of zero groups shortened to `::`, as RFC 5952 and the URL parser write it, so `0:0:0:0:0:0:0:1` produces `::1` and one address is one string. An IPv4-mapped address (`::ffff:0:0/96`) or one under the NAT64 well-known prefix (`64:ff9b::/96`) comes out with its IPv4 part dotted, as RFC 5952 section 5 recommends, however it was written: `::ffff:c000:201` produces `::ffff:192.0.2.1`. Every other IPv6 address comes out in hex groups, the deprecated IPv4-compatible ones included, since that range also holds `::1`: `::1.2.3.4` produces `::102:304`. `cidr()` spells its address the same way. An IPv4 address has one spelling and is produced as written. Without it both are accepted, and any other value throws a `TypeError` when the validator is created. The `format` in the issue is `"ipv4"` or `"ipv6"` when a version is given, and `"ip"` otherwise.

IPv4 is four decimal parts from 0 to 255 without leading zeros, which some parsers read as octal. IPv6 is written as RFC 4291 allows: eight groups of one to four hex digits, one run of zero groups shortened to `::`, and an IPv4 address in place of the last two groups, as in `::ffff:192.0.2.1`, whose parts follow the IPv4 rule. A zone such as `%eth0` is accepted only after a link-local address (`fe80::/10`), the one place it means something.

### `datetime` and `time`

`datetime(options?)` requires the `T` separator, a time, and `Z`, both in uppercase, so the lowercase `t` and `z` and the space for the `T` that RFC 3339 also allows fail, and rejects days that do not exist, so `2026-02-30T00:00:00Z` fails. The options are `offset`, default `false`, which accepts a UTC offset such as `+02:00` instead of only `Z`, and is what `+00:00` needs, as Python's `isoformat()` and Java's `OffsetDateTime` write UTC, `local`, default `false`, which also accepts a date-time without a zone, a time on a local clock such as `2026-09-28T14:30` from an HTML `datetime-local` input, whose seconds may be left out as that input leaves them out, while a time with a zone still needs its seconds, and `precision`, an integer from 0 to 9, which requires exactly that many fractional-second digits (`0` forbids them; without it they are optional) and, with `local`, the seconds. A `precision` that is not a number throws a `TypeError` when the validator is created, and one outside that range a `RangeError`. Leap seconds (`23:59:60`) are rejected. `coerceDate` reads more spellings than this, such as a space for the `T`, an offset without its colon, or, with `zoneless: "utc"`, no zone at all, because it converts text rather than checks its form; [Coercion](docs/coercion.md) lists them.

`time(options?)` accepts `HH:MM`, as an HTML time input writes it, and `HH:MM:SS` with an optional fraction. It takes `precision` as `datetime` does, which also makes the seconds required.

### Parts of an email or a URL

`url` and `email` are built from parts, and each part can be checked with a validator of your own. A part validator receives what the parser read, not the text, and replaces the format's default rule for that part, never its syntax:

| Option                 | Receives                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Default rule               |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `url({ credentials })` | `{ username, password }` as the parser writes them, percent-encoded, `password` empty when only a user is written, or `undefined` when the URL names neither. The URL produced keeps them.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | No credentials             |
| `url({ host })`        | The host: a domain in lowercase ASCII with internationalized labels in punycode, an IPv4 address as four decimal parts, or an IPv6 address without its brackets, spelled as `ip()` spells it, while the URL produced keeps the parser's spelling. For a scheme the parser has no rules for, such as `ssh`, the name as written, with its letters in lowercase and its escapes in uppercase. The parser does not read such a host as an address, so `redis://2130706433/`, `redis://0x7f.1/` and `redis://%31%32%37.0.0.1/` reach it as written, while a client may connect to `127.0.0.1`: check it with `hostname()`, `domain()` or `ip()`, which reject those spellings, never with a list of strings. | A public domain name       |
| `url({ port })`        | The port as a number, or `undefined` when the URL names none or names its scheme's default, which the parser drops.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | Any port but 0             |
| `url({ path })`        | The path as the parser writes it, dot segments resolved and characters such as spaces percent-encoded, starting with `/`, or empty for a URL of a scheme the parser has no rules for, such as `ssh://example.com`, that names no path.                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | Any path                   |
| `url({ query })`       | The query as an object of its decoded parameters, each key's value a string, or with `repeated: true` every value of every key as an array.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Any query                  |
| `email({ domain })`    | The domain as `email` produces it, lowercase ASCII with internationalized labels in punycode. It must still be a hostname, so never an IP address.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | A public domain name       |
| `email({ local })`     | The local part, before the `@`, as written.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | The syntax of a local part |

```ts
import { domain, email, hostname, ip, object, oneOf, optional, port, startsWith, string, union, url } from "@codenhub/validation";

url({ host: hostname() }); // any hostname, localhost included
url({ protocols: ["postgres"], host: hostname(), credentials: optional(object({ username: string(), password: string() })) }); // a DATABASE_URL
url({ host: union([domain(), ip()]) }); // IP addresses too, but not localhost
url({ host: oneOf(["api.example.com"]), port: optional(port()) });
url({ protocols: ["https"], path: string(startsWith("/api/")) });
url({ query: object({ page: optional(string()) }, { unknownKeys: "strict" }) }); // no other parameters
email({ domain: oneOf(["company.com"]), message: "Use your company address" });
```

A part that fails is reported as the format's own issue, at the place of the URL or address, so a form shows it beside the field the text was typed in: `invalid_format` with `params` `{ format: "url", part: "host", issues }` or `{ format: "email", part: "domain", issues }`, where `issues` holds what the part validator found, with paths relative to the part, such as `["page"]` inside the query. The format's `message` words it, as it words every other issue of the format, so `email({ domain: oneOf(["company.com"]), message: "Use your company address" })` says that sentence for an address at another domain, and `englishMessages` words it with what the part found first, as in "Invalid email address domain: Expected one of "company.com"". A part only decides: the value is still the whole URL or address, so a part that converts its value, such as `coerceNumber` inside `query`, changes nothing in the output. To read typed values from a query, use [`searchParams`](#searchparams). A part validator that is asynchronous makes the format asynchronous.

The parts apply to a URL with a host. A `mailto`, `tel` or `urn` URL keeps its own rules above.

A query key given more than once, as in `?id=1&id=2`, fails unless `repeated` is set, as a failure of the query part whose `issues` hold `invalid_key` at `[key]`. A check that saw one of the two values while a server read the other would pass a value nobody checked, so repeated keys are accepted only when you ask for every value.

With a `path` validator, a path that holds an encoded `/` or `\`, `%2F` or `%5C` in either case, fails before any part runs, as a failure of the path part whose `issues` hold `invalid_value` with `{ encodedSeparator: true }`. The parser and a check on the path read `/api/..%2fadmin` as one segment under `/api/`, while a server that decodes the separator before routing reads `/admin`, so a prefix check would pass a path it does not cover. A path holding a segment `.` or `..`, a dot written plain or as `%2E`, followed by `;` or `%3B` fails the same way, with `{ dotSegment: true }`: the parser reads `/api/..;/admin` as a segment named `..;` under `/api/`, while a server that drops the parameters before resolving dot segments, as Tomcat and Jetty do, reads `/admin`, and a proxy that decodes the path, such as nginx, passes `..%3B` on to it as `..;`. A `;` anywhere else, as in `/api/a;v=1`, is accepted. Write a `path` validator as an allowlist, a description of the paths that may pass, such as `string(startsWith("/api/"))`: a server may read a path in ways the URL Standard does not, decoding `%61` to `a`, merging `//` into `/` or dropping `;` and what follows from a segment, so a denylist such as "not under `/admin`" passes `/;/admin`, `//admin` and `/%61dmin`, while an allowlist still holds for every such reading of what it accepts. Without a `path` validator such a URL is accepted, since nothing checks its path. For a scheme the parser has no rules for, such as `ssh`, a `\` in the path is kept as it was written and is no separator to the parser, so `foo://host/api/..\admin` passes `startsWith("/api/")`; a consumer that reads `\` as a separator, as one on Windows may, needs an allowlist that leaves it out, such as `pattern(/^\/api\/[\w/-]*$/)`.

### `searchParams`

`searchParams(validator, options?, ...checks)` reads a query string, with or without its `?`, or a `URLSearchParams`, from this realm or another such as an iframe, into an object of its decoded parameters, and produces what `validator` makes of it. It reads the parameters as `url`'s `query` part does, `+` as a space and escapes decoded, and rejects a repeated key the same way unless `repeated` is set. Anything else fails with `invalid_type` and `{ expected: "query string", received }`.

```ts
import { array, coerceNumber, object, optional, searchParams, string } from "@codenhub/validation";

const filters = searchParams(object({ page: coerceNumber({ int: true, min: 1 }), q: optional(string()) }));
filters("?page=2&q=red+shoes"); // { ok: true, value: { page: 2, q: "red shoes" } }

const tags = searchParams(object({ tag: array(string()) }), { repeated: true });
tags("tag=a&tag=b"); // { ok: true, value: { tag: ["a", "b"] } }
```

## Objects

`object(shape, options?, ...checks)` takes a shape, an object whose values are validators, and produces an object with the same keys and the output of each validator.

```ts
import { number, object, optional, string } from "@codenhub/validation";

const user = object({ name: string(), age: optional(number()) });

user({ name: "Ada" }); // { ok: true, value: { name: "Ada" } }
user({ age: "x" }); // two issues: ["name"] and ["age"]
```

- Only plain objects are accepted, whichever realm made them. Arrays, class instances, objects with a prototype of their own, `Map`s, `Date`s and `null` fail with `invalid_type` and `{ expected: "object", received }`.
- Only own properties are read, so a value inherited through the prototype never satisfies a required property.
- Every property is validated, even after an earlier one failed, and each issue's path leads down to it.
- A property whose validator accepts `undefined`, such as one wrapped in `optional`, is optional in the inferred type, and is left out of the output when absent from the input.
- The output is a new object and the input is never modified. The copy is shallow: a property's value is what its validator produced, so a value an `unknown()` or a passthrough key lets through is the input's own object, and changing it in the output changes the input too.

The `unknownKeys` option decides what happens to input properties the shape does not list:

| `unknownKeys`   | Effect                                                                                 |
| --------------- | -------------------------------------------------------------------------------------- |
| `"strip"`       | Drop them from the output. This is the default.                                        |
| `"strict"`      | Reject each with an `unrecognized_key` issue at the key's path, and `params: { key }`. |
| `"passthrough"` | Copy them to the output unchanged and unchecked.                                       |

Any other `unknownKeys` value, such as a misspelled `"Strict"`, throws a `TypeError` when the validator is created, instead of falling back to `"strip"`.

A check given to `object` runs once every property has passed, and sees the whole object, so it can compare fields. Point its issue at the field to fix with `path`:

```ts
import { check, object, string } from "@codenhub/validation";

const signup = object(
  { password: string({ min: 8 }), confirm: string() },
  check((data) => data.password === data.confirm, { path: ["confirm"], message: "Passwords must match" }),
);
```

See [Reusing shapes](#reusing-shapes) for extending, omitting and making properties optional.

### `objectLike`

`objectLike(shape, options?, ...checks)` validates any object that has the listed properties, where `object` accepts plain objects only. Use it for a value handed over as an instance, such as an `Error` or an object with methods, and `object` for data. To choose between instances by a property, put the `objectLike` validators in a `union`: `tagged` reads the tag of a plain object only, and rejects an instance before any variant sees it.

```ts
import { boolean, objectLike, optional, string } from "@codenhub/validation";

const feedback = objectLike({ message: string({ min: 1 }), isRetryable: optional(boolean()) });

feedback(new Error("Try again")); // { ok: true, value: { message: "Try again" } }
feedback({ message: "" }); // one issue: too_small at ["message"]
```

- Every object that is not an array is accepted: a plain object, a class instance, a `Map`, a `Date`. Anything else, a function included, fails with `invalid_type` and `{ expected: "object", received }`.
- Each listed property is read as `input[key]`, so one that is inherited, not enumerable or computed by a getter counts.
- A property that throws while it is read, as a getter or a `Proxy` trap may, is reported as `invalid_value` with `params: { unreadable: true }` at the property's path, and the other properties are still validated. `object` lets such an exception propagate.
- The output is a new plain object holding the listed properties only, without the ones whose value is `undefined`. There is no `unknownKeys` option.
- Checks run once every property has passed, and see the output.

## Collections

Every collection validator takes the validator for its items, then options and checks, checks every item, and reports each issue with a path that leads through the item's position. A wrong size is reported at once, without validating the items, so a huge input is never worked through only to be rejected.

The size options `min`, `max` and `length` are non-negative integers; `tuple`, whose length its items set, takes only `max`, with `rest`. Each throws a `TypeError` when created with a value that is not a number, such as `"3"`, and a `RangeError` with a number that is not a non-negative integer, as do limits no size can satisfy together, such as `min` above `max` or a `length` outside them. `min` and `max` are inclusive. A size failure is `too_small` with `{ minimum, type }` or `too_big` with `{ maximum, type }`, where `type` is `"array"`, `"set"`, `"map"` or `"record"`, and `exact: true` is added for `length`. A record's size is its number of keys.

A collection stops once its items have reported 1,000 issues. The items after that are not validated, and one more issue, `too_big` with `{ maximum: 1000, type: "issues" }` at the collection's path, says that it stopped. An issue that holds others in `params.issues` counts with them, so an item that fails a `union` counts once and once more for each issue of each option, and a key that fails counts with the issue behind it. The unknown keys of a strict `object`, the repeats `unique` and `set` find, the keys a query string repeats and the conflicts of an `intersection` are listed up to the same limit. Only issues are counted, so input that passes is never affected and input that fails still fails: what changes is that a long list of bad items is not reported in full, and does not cost the memory and time of reporting it. To report every bad row of a long list, such as an import, call the item validator on each row yourself. Items that are validated asynchronously have all started before any is counted, so the limit cuts what they report and not their work: give such a collection a `max`.

### `array`

`array(item, options?, ...checks)` accepts arrays whose every item passes `item` and produces a new array of what it produced. It takes `min`, `max` and `length`. A sparse array's holes are validated as `undefined`, so the work grows with its `length`, not with the items it holds: a value received by structured clone, such as `event.data` from `postMessage`, a worker or Electron IPC, can be a 28-byte message holding an array of 4,294,967,295 holes. Give untrusted input a `max`, which is checked before any item.

The `unique(by?, message?)` check rejects repeats, comparing the validated items the way a `Set` does, or the key `by` returns for each, so `unique((user) => user.id)` makes ids unique. Each repeat is reported at its own index as `invalid_value` with `{ unique: true }`, up to the limit of issues described above, and the first occurrence is kept. Like every check, it runs only once every item is valid. A `Set` finds two objects equal only when they are the same object, every object or list a composer such as `object` or `array` produces is new, and two `Date`s of one moment are two objects, so `unique()` without `by` would find a repeat among them only where a validator passed the same object through twice, such as one `Date` given twice to `date()`. Its types accept it only for an array of primitives; an array of objects, lists or dates needs `by`, such as `unique((user) => user.id)` or `unique((day) => day.getTime())`.

```ts
import { array, string, unique } from "@codenhub/validation";

const tags = array(string({ trim: true, min: 1 }), { max: 5 }, unique());

tags(["a", "b"]); // ok
tags(["a", "a"]); // fails: invalid_value at path [1]
```

### `tuple`

`tuple(items, options?)` accepts arrays of fixed length in which each position has its own validator, and produces a tuple type. Without `rest` the array must be exactly as long as `items`, and a wrong length fails with `too_small` and `{ minimum, type: "array", exact: true }`, or `too_big` and `{ maximum, type: "array", exact: true }`. With `rest` the array must still have at least as many items as `items`, and fails with `too_small` without `exact` when it has fewer. The `rest` option is a validator for every position after the fixed ones, which lets the array be longer, and `max`, allowed only with `rest`, caps how long, the fixed items included: a longer array fails at once with `too_big` and `{ maximum, type: "array" }`, without validating the items. Give untrusted input a `max`, as you would an `array`.

```ts
import { number, string, tuple } from "@codenhub/validation";

const point = tuple([number(), number()]); // [number, number]
const call = tuple([string()], { rest: number(), max: 10 }); // [string, ...number[]], at most 10 items
```

### `record`

`record(key, value, options?)` accepts plain objects used as a dictionary: any number of keys, each passing `key`, each value passing `value`. An issue's path ends at the key it belongs to. A key that fails is reported as one `invalid_key` issue whose `params.issues` holds what the key validator found, so it is not mistaken for a problem with the value, and a bad key still has its value checked. A key that the key validator changes, such as by lowercasing, must stay distinct: `{ A: 1, a: 2 }` under `string({ case: "lower" })` reports the second as `invalid_key` whose `params.issues` holds one `invalid_value` issue with `{ unique: true }`, rather than dropping a value. The output type has every key when `key` produces `string`, and is partial when it produces a fixed set of strings, such as `oneOf(["mon", "tue"])`. A `__proto__` key from parsed JSON is kept as data and never writes to a prototype. It takes `min`, `max` and `length`, which count keys, so `record(string(), number(), { max: 100 })` rejects a dictionary of more than 100 keys before checking any of them.

### `set` and `map`

`set(item, options?)` accepts `Set`s and produces a new `Set` of the validated values, and `map(key, value, options?)` accepts `Map`s and produces a new `Map`. Arrays and plain objects are not accepted for them. Paths lead through the position in iteration order for a set, and through the entry's key for a map when it is a string and its position, a number, for any other key, so a number key and an object key never share a path. A map key that fails is reported as `invalid_key`, as for `record`, and so is one that another entry has already taken once the key validator has changed it. Likewise a set value that `item` makes equal to an earlier one, such as `"A"` and `"a"` under `string({ case: "lower" })`, is reported as `invalid_value` with `{ unique: true }` at its position rather than merged, so the output never holds fewer values than the size options allow. Both take `min`, `max` and `length`.

## Combining validators

### `optional`, `nullable` and `nullish`

- `optional(validator)` accepts `undefined`, and `optional(validator, value)` replaces it with a default, [below](#defaults).
- `nullable(validator)` accepts `null`.
- `nullish(validator)` accepts both.

The accepted value is passed through, and every other value goes to `validator`, so `null` and the empty string are not treated as absent by `optional`. These wrappers report no issue of their own, so they take no options or checks; give those to the validator they wrap. Inside an `object`, a property whose validator can produce `undefined` becomes optional in the inferred type, and is left out of the output when absent from the input.

### Defaults

`optional(validator, value)` replaces `undefined` with a default, and gives every other value to `validator`, so an invalid value is still rejected and only a missing one is replaced. The default is trusted and is not validated. A primitive is used as it is, and a function is called every time, with no argument, to produce the default. An array or object must come from a function, such as `optional(array(string()), () => [])`, since one value would be shared by every result and a change to one would show up in the next: the types reject it, and `optional` throws a `TypeError` when created from JavaScript. For the same reason, a default that is itself a function, such as a callback for `func()`, has to be returned from one: `optional(func(), () => noop)`. Passing `noop` directly would call it and use what it returns, so the types reject it whenever the wrapped validator can produce a function. Inside an `object`, the property is then always present in the output type. The type also assumes `validator` never produces `undefined` for a value that is present, which every built-in validator keeps; a `transform` that returns `undefined` is let through as it is, and the default replaces only a missing value.

```ts
import { array, oneOf, optional, string } from "@codenhub/validation";

const role = optional(oneOf(["admin", "user"]), "user");
const tags = optional(array(string()), () => []);
```

### `fallback`

`fallback(validator, value)` replaces a value that fails `validator` with a fallback, so the result never fails. The fallback is trusted and is not validated, and a function receives the issues that were found, which is the place to log them. A primitive is used as it is. An array or object must come from a function, such as `fallback(array(string()), () => [])`, since one value would be shared by every result and a change to one would show up in the next: the types reject it, and `fallback` throws a `TypeError` when created from JavaScript. A function is called with the issues, so a fallback that is itself a function has to be returned from one: `fallback(validator, () => callback)`, and the types reject the callback itself whenever the wrapped validator can produce a function. For the same reason a function that reads an argument, such as `Array` or `String`, would be given the issues: write `() => []`, not `Array`. A value whose validation `lazy` stopped at one of its limits, such as cyclic input or input nested past `maxDepth`, fails too, so it is replaced like any other failure; when those limits must surface, keep `fallback` off a recursive schema. This turns bad input into a valid-looking value, so reserve it for data where a sensible default is safer than an error, such as a stored preference that may be out of date.

### `pipe`

`pipe(a, b, ...)` runs validators in order, giving each the value the previous one produced, and produces what the last produces. The first failure stops it, because a later step has nothing valid to work on. Use it to clean a value before checking a format, or to check one rule after another.

### `transform`

`transform(validator, convert)` changes the value a validator produced into another, such as text into a `Date`. `convert` runs only when `validator` succeeded and cannot reject the value: to reject what it produced, put a validator with checks after it with `pipe`. A `convert` that returns a promise, or is typed as possibly returning one, including one typed as returning `unknown`, makes the result asynchronous, and so does any value with a `then` method, as `await` would treat it, so a value that has one for another reason has to be wrapped in an object before it is returned. One typed as returning `any`, such as `JSON.parse`, has opted out of type checking and is typed synchronous, so `transform(string(), JSON.parse)` needs no `await`; if it can return a promise, say so in its return type. A `convert` that throws is a bug and propagates.

```ts
import { string, transform } from "@codenhub/validation";

const length = transform(string(), (text) => text.length);
```

## Checks

A check is a rule about a value that already has its type, given to a validator after its options. It runs once the value has its type and has passed the validator's own options: for a leaf, once the input has passed the type test and every option, such as `min`, `max` or `int`; for a format, once the text is of the format, so a check given to `ip()` or `uuid()` never sees text that is not an address or a UUID; for an object or a collection, once every child has passed, since before that there is no value of the type to check. While an option fails, no check runs and only the issues of the options are reported, every one that fails, so `max` keeps a long string from a costly `pattern`, and an asynchronous check, such as one that asks a server whether a name is taken, never sees a value the options already rejected. Once the options pass, every check runs and every issue is reported.

```ts
import { check, number, string, startsWith, uppercase } from "@codenhub/validation";

string({ min: 2 }, startsWith("A"), uppercase());
number(check((n: number) => n % 2 === 0, "Must be even"));
```

The built-in checks, each its own import, take their message last:

| Check              | For     | Requires                                                                                                                                                                                                                                                                | `code` and `params`                                                |
| ------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `pattern(regex)`   | strings | A match. The `g` and `y` flags are ignored, so the same check gives the same answer each time. The English wording shows the expression, `Must match /^[a-z0-9_]+$/`, so give the check a message, `pattern(regex, "Use letters, digits and _")`, where people read it. | `invalid_format`, `{ format: "regex", pattern }`                   |
| `startsWith(text)` | strings | This prefix.                                                                                                                                                                                                                                                            | `invalid_format`, `{ format: "startsWith", value }`                |
| `endsWith(text)`   | strings | This suffix.                                                                                                                                                                                                                                                            | `invalid_format`, `{ format: "endsWith", value }`                  |
| `includes(text)`   | strings | This text anywhere.                                                                                                                                                                                                                                                     | `invalid_format`, `{ format: "includes", value }`                  |
| `lowercase()`      | strings | Unchanged by `toLowerCase()`, so a string with no letters passes. It changes nothing; `string({ case: "lower" })` converts.                                                                                                                                             | `invalid_format`, `{ format: "lowercase" }`                        |
| `uppercase()`      | strings | Unchanged by `toUpperCase()`.                                                                                                                                                                                                                                           | `invalid_format`, `{ format: "uppercase" }`                        |
| `nonBlank()`       | strings | A character that is not white space, so `""` and `"  "` fail. It changes nothing; `string({ trim: true, min: 1 })` trims.                                                                                                                                               | `invalid_format`, `{ format: "nonBlank" }`                         |
| `multipleOf(step)` | numbers | A multiple of the step, compared as the decimals both are written as, so `0.3` is a multiple of `0.1` at any size.                                                                                                                                                      | `invalid_value`, `{ type: "number", format: "multipleOf", value }` |
| `nonZero()`        | numbers | Anything but zero.                                                                                                                                                                                                                                                      | `invalid_value`, `{ type: "number", format: "nonZero" }`           |
| `unique(by?)`      | arrays  | Distinct items, [above](#array).                                                                                                                                                                                                                                        | `invalid_value`, `{ unique: true }`, at each repeat's index        |

A `pattern` that is not a regular expression, and a `multipleOf` step that is not a positive finite number, throw when the check is made. `multipleOf` is exact for every number as it is written, the shortest text that reads back as it, which is what JSON carries. Past `Number.MAX_SAFE_INTEGER` that text is not always the number the double holds: `2 ** 60` is written `1152921504606847000`, so it is not a multiple of `1024`, though `1e23` is one of `10`. A number computed in floating point is not always the decimal it looks like: `0.1 + 0.2` is `0.30000000000000004`, which is not a multiple of `0.1`.

`check(test, issue?)` makes a check of your own; [Custom validators](docs/custom-validators.md) covers it and the other builders. A check that returns a promise makes its validator asynchronous.

## Choosing between validators

### `union`

`union(options)` accepts a value that passes any one of several validators and produces what the first one that accepts it produced, so put the more specific options first: `union([coerceNumber(), string()])` produces the number `5` for `"5"`, and `union([string(), coerceNumber()])` the string. A value that none accepts fails with one `invalid_union` issue at the value's own location, and `params.issues` lists, for each option in order, the issues it found. Those paths are relative to the value the union received. A union that failed inside an option, such as one for a property of an object option, is listed with its code and path, and without the issues of its own options, by the rule [issues held in `params.issues`](docs/errors.md#issues-behind-an-issue) follow: in a recursive union every option reaches the same children, so holding them in full would nest once per level and write the same issues out once for every path through that nesting, which made 430 bytes of input a result of 350 MB of JSON. The issues of one level of options are what a message is built from; call the inner union on the value for the rest. A limit of [`lazy`](#lazy) that stopped the validation is kept. Found in an option of this union, it is listed in `params.issues` among that option's issues; found below a union held there, the `too_big` issue stands in place of that union, at its full path. A limit fails only the option that reached it, so another option can still accept the value, unless it needs the same `lazy`, whose calls stay spent for the rest of the validation. Every option runs until one accepts, so in a recursive union of objects every option reaches the children; a [`lazy`](#lazy) between them gives its result for each child again rather than validating it again, so the work grows with the input. Use [`tagged`](#tagged) for objects told apart by a property, which validates only the matching variant.

```ts
import { number, string, union } from "@codenhub/validation";

const id = union([string({ min: 1 }), number({ int: true })]);
```

### `tagged`

`tagged(key, variants, options?, ...checks)` is for objects that share a tag property and differ in the rest, such as events with a `type`. It takes the name of the tag property and a record with a validator for each tag value. The input's tag chooses the variant, the variant validates the rest of the input, and a failure reports that variant's own issues instead of a list of everything that did not match.

```ts
import { number, object, string, tagged, type Infer } from "@codenhub/validation";

const event = tagged("type", {
  click: object({ x: number(), y: number() }),
  key: object({ key: string({ min: 1 }) }),
});

type Event = Infer<typeof event>;
// { type: "click"; x: number; y: number } | { type: "key"; key: string }

event({ type: "key", key: "a" }); // { ok: true, value: { type: "key", key: "a" } }
```

**A variant must not list the tag.** Unlike some other libraries, where each variant is `object({ type: literal("click"), ... })`, a variant here is given the input without its tag, so one that lists it could never pass. Its type is rejected, so the mistake is a compile error at that variant. The record key already says which tag the variant is for.

Because a variant never sees the tag, a strict `object` works as a variant, and so does a `record`, which checks every key but the tag. An output that declares the tag beside an index signature is still rejected, since the variant is never given it. The type of a record variant has an index signature beside the tag, so reading it and narrowing on the tag work, but TypeScript rejects writing such an object out by hand when the tag is not of the record's value type. The tag is added back to the output, so the result is a proper tagged union and checking `event.type` narrows the type. A missing, unknown or non-string tag fails with `invalid_union`, at the tag's path, with `params: { discriminator, options }` listing the accepted tags.

Each variant must produce a plain object, since that is what can carry the tag. A variant typed to produce an array or a function is a compile error. One that produces any other non-plain value at runtime, such as a `Date` or a class instance from a `transform`, throws a `TypeError` naming the variant, rather than being taken apart into a plain object, because it is a mistake in the schema and not in the input.

### `intersection`

`intersection(left, right)` accepts a value only when it passes both validators, reports the issues of both together, and produces the two outputs merged. Plain objects are merged key by key and arrays of the same length item by item, recursively. Two `Map`s or two `Set`s of the same size are merged entry by entry in iteration order, which both validators keep from the input, so a map keyed by objects or a set of objects merges too, and a conflict inside one is reported at the entry's string key or, for any other key and for a set, at its position. Cyclic or shared objects in the outputs are merged once, and the merged output keeps their shape. Any other pair must be the same value, `0` and `-0` merging as `0`, or two dates holding the same moment: where the outputs differ otherwise, as `"  ab "` does trimmed on one side and uppercased on the other, no value satisfies both, so each such place fails with `invalid_intersection` at its path instead of one side silently winning.

Both validators see the whole input, so two `object`s with `unknownKeys: "strict"` can never pass together: each rejects the keys only the other lists. To combine strict shapes, spread them into one: `object({ ...named, ...aged }, { unknownKeys: "strict" })`.

## Recursive data and JSON

### `lazy`

`lazy(getter, options?, ...checks)` looks up another validator the first time it runs, so a validator can refer to itself. TypeScript cannot infer a type that refers to itself, so annotate the variable with the type it produces:

```ts
import { array, lazy, object, string, type Validator } from "@codenhub/validation";

interface Category {
  name: string;
  children: Category[];
}

const category: Validator<Category> = object({
  name: string(),
  children: array(lazy(() => category)),
});
```

Each level of nesting is one level of recursion, which the JavaScript stack can only hold so many of, so `lazy` counts them. `lazy(getter, { maxDepth })` takes the most levels of `lazy` that may be open at once, 128 by default, counting every `lazy` validator and not only that one, and a value found deeper fails with `too_big` and `{ maximum, type: "depth" }` at its own path. One count for every `lazy` is what bounds the stack when two recursive validators call each other, and it means a limit is checked against every level already open: `lazy(getter, { maxDepth: 1 })` used inside any other `lazy` fails at once, because the outer one is already one level deep. Set `maxDepth` for the whole nesting, not for one validator's share of it. A request body of thousands of nested arrays and a cyclic object, which a recursive validator would follow forever, both come back as that failure and never throw. The count is of `lazy` calls on the stack, so it bounds recursion that happens in one synchronous run, where the stack can overflow. A rule that awaits before it reaches the next level starts that level from a fresh stack and is not counted, so `maxDepth` does not bound an asynchronous recursive schema. `maxCalls`, below, does: each level is a `lazy` call, and the count of calls lasts across awaits, so such a schema stops after that many calls, given a cyclic object too. Raise `maxDepth` only for data you know is deeper, and only as far as the stack of your runtime holds for the validators you wrote. How much a stack holds is the runtime's own, and a worker has about half of a page's in Chromium and Firefox: a recursive `union` of objects, as a document tree is, overflows near 730 levels on a page in Chromium and near 260 in a worker, twice the default. A schema with many validators at each level has less room, and one with seven composers and three checks at each level threw a `RangeError` for valid input 126 levels deep in a worker of either, so lower `maxDepth` for such a schema where it runs in a worker. `maxDepth` must be a positive integer, or `lazy` throws when created.

Work can also grow faster than the input. A `union` tries every option and an `object` checks every property even after one has failed, so in a recursive `union` of objects the options that do not match still reach the children, at every level. A `lazy` keeps what it found for each object at each path in one validation and gives it again when the same object is reached at the same path, so those options share the children's result and the work grows with the input; an asynchronous check under it runs once for each such object and path, however many options reach it. A primitive is not kept, since it has no children to multiply the work. Work that makes a new object at every level, such as a `transform` that copies its value before a `lazy`, cannot be shared, and its work doubles with each level: a valid input of 500 bytes, 22 levels deep, takes seconds, and one a little deeper takes hours. `lazy(getter, { maxCalls })` stops that: it is the most calls that `lazy` may make in one validation, 10,000 by default, and past it every further call of it fails with `too_big` and `{ maximum, type: "calls" }`. Each `lazy` counts its own calls against its own limit, so a limit set on one is never overridden by another the validation reaches, and the work of a validation is bounded by the limits of the `lazy` validators it reaches together. A validation is a call such as `body(input)` and everything it reaches until its result settles, whatever validator is at its root, so the items of an `array` of recursive values share each `lazy`'s count, and validations made one after another, such as one per request, have counts of their own. So does a validation made from a callback you wrote, such as a `check`'s test, a `transform`'s function, a `guard`, a `format`'s test, the default of `optional` or `fallback`, or a `lazy` getter: it is counted apart from the validation that ran the callback, while a validator given as a child, even one written by hand, is part of the validation that reaches it. Unlike `maxDepth`, it lasts across awaits: what runs after an asynchronous rule settles counts toward the same validation, so an asynchronous recursive schema is held to the limit too. That holds for the awaits of this package, such as an asynchronous `check`'s. An await inside a validator you write yourself is not seen: what that validator calls after it starts a validation of its own, with fresh counts, so `maxCalls` does not bound recursion through it. Put asynchronous work in a `check`, or bound such a validator yourself. A validation stopped by the limit keeps the issues of every option that failed on the way, about 1 KB per call, so the default holds each `lazy` to about 10 MB and a few tenths of a second. Set the limit on every `lazy` of the schema, lower for small untrusted input:

```ts
import { array, lazy, literal, object, union, type Validator } from "@codenhub/validation";

const kids = array(lazy(() => node, { maxCalls: 5_000 })); // one limit for every node of the request body
const node: Validator<unknown> = union([object({ type: literal("a"), kids }), object({ type: literal("b"), kids })]);
const body = array(node, { max: 100 });
```

A `lazy` made while another `lazy`'s getter runs shares that one's count, and each holds the count to its own `maxCalls`. That is what bounds a schema built anew at every level, by a getter that calls the function building it, as in `const node = () => union([object({ kids: array(lazy(node)) }), …])`: every level makes new `lazy` validators, which would otherwise each get a count of their own, and its work would have no bound. Such a schema shares no results either, since every level is a new validator, so its work doubles with each level until the limit stops it, and it rejects deep valid input that a schema built once accepts. Build a recursive schema once and refer to it, also inside a function that builds it for you:

```ts
import { array, lazy, object, string, type Validator } from "@codenhub/validation";

interface Comment {
  text: string;
  replies: Comment[];
}

const thread = (maxCalls: number): Validator<Comment> => {
  const comment: Validator<Comment> = object({ text: string(), replies: array(lazy(() => comment, { maxCalls })) });
  return comment;
};
```

Raise `maxCalls` for recursive data with more nodes than that in one validation. Every object, array or other value with parts that reaches the `lazy` is a call, not only every level, so a schema for any JSON value, whose arrays and records hold `lazy` items, stops at 10,000 of them however flat they are. A primitive that reaches the `lazy` and ends the recursion there, such as a number in a list, is not counted, so a list of a million numbers is held to no limit but its `max`; one the recursion goes on through, such as text a `json` or a `transform` reads the next level from, is counted, and so is one whose result is pending, as under an asynchronous `check`. A `lazy` under a `union` fails as that option, so the code the caller sees is the union's `invalid_union`, with the `too_big` issue inside `params.issues`, and `englishMessages` words the union with it, "Too complex to check within 10000 recursive steps", whatever the other options found, since the input may be valid and only too large. Like `maxDepth`, it must be a positive integer, or `lazy` throws when created. For objects told apart by a property, use [`tagged`](#tagged), which reads the property first and validates only the matching variant.

The limits are about the stack and the work per node and not about size, so they do not stop a large flat input. The issues of a failing value do not grow with it, since each collection stops at 1,000 of them, as described with the size options. The work on a value that passes, or that fails only near its end, still grows with its size, so cap the size of untrusted input, for instance with `pipe(string({ max: 100_000 }), json(category))`, and give `array` a `max`. A value received by structured clone, such as `event.data` from `postMessage`, needs the `max` whatever its size in bytes, since it can hold a sparse array or one array at many places, so its items are not bounded by its bytes.

### `json`

`json(validator?, options?, ...checks)` accepts text that holds JSON, parses it, and then gives the parsed value to `validator` when there is one. A non-string fails with `invalid_type`, and text that is not JSON fails with `invalid_format` and `{ format: "json" }`. Without a validator the result is `unknown`. The text is parsed by `JSON.parse`, so a key given twice keeps its last value, as `{"a":1,"a":2}` gives `{ a: 2 }`, and a number beyond what a double holds exactly loses digits, as `12345678901234567890` becomes `12345678901234567000`: send such numbers as strings and read them with `coerceBigint`. Paths of issues from `validator` are relative to the parsed value. A function in first place is the validator, so checks need one: `json(object(shape), {}, check(...))`. Anything else in first place but options throws a `TypeError` when created, `undefined` included, so a validator that is missing, such as one looked up under a key that does not exist, never makes a `json` that accepts any JSON.

## Reusing shapes

A shape is an ordinary object, so it is reused with ordinary JavaScript: spread one into another to extend it, and leave keys out with destructuring. `partial(shape)` returns a new shape with every property wrapped in `optional`, for an update where any field may be left out. The original shape is unchanged, so the required version is still there to use. A property left out stays out, so a default given with `optional(validator, value)` in the shape is not applied by its `partial` version: an update leaves out what it does not change. A shape holds enumerable text keys only, and `object`, `partial` and `tagged` throw a `TypeError` when created for a symbol key or one that is not enumerable, whose validator would never run.

```ts
import { email, number, object, partial, string } from "@codenhub/validation";

const user = { name: string({ min: 2 }), email: email() };

const create = object(user);
const update = object(partial(user)); // every property optional
const withAge = object({ ...user, age: number() });
```

## Coercing text input

The coercing validators accept text that holds a value, convert it, and then apply the constraints of their strict counterpart: `coerceString` and `string`, `coerceNumber` and `number`, `coerceBoolean` and `boolean`, `coerceBigint` and `bigint`, `coerceDate` and `date`. They take the same options, `coerceDate` also `zoneless`, which says how to read a date-time without a zone, and fail with `invalid_type` and `coerced: true` in `params` when the value cannot be converted. [Coercion](docs/coercion.md) lists exactly what each accepts and refuses, and how to read a whole environment or form with them.

## Exposing a validator to other libraries

`standard(validator, messages)` returns the validator with the `~standard` property that [Standard Schema](docs/standard-schema.md) asks for, so libraries that accept one can take it directly. Its `messages` map, such as `englishMessages`, supplies the text that specification requires on every issue.

## Wording one validator

Every validator that reports an issue of its own takes a `message` option: the text, or a function that words an issue from its `code` and `params`. The function is given the issue as that validator reports it, so its `path` is relative to the validator's own value, `[]` for the value itself, even where the result places the issue deeper inside an `object` or an `array`: word it from the field you gave the option to, not from the path. It words every issue that validator reports itself, and every issue one of its checks reports without a message of its own, and none a child reports, so `object(shape, { message })` words an input that is not an object and leaves each property's issues to that property. Every built-in check takes a message as its last argument, which a check that needs its own sentence is given: `string({ message: "Invalid username" }, pattern(/^\w+$/))` says "Invalid username" for either failure, never the pattern.

```ts
import { email, object, startsWith, string } from "@codenhub/validation";

const form = object({
  name: string({ min: 2, message: "Enter your full name" }),
  email: email({ message: (issue) => t(`errors.${issue.code}`) }),
  code: string(startsWith("INV-", "Invoice codes start with INV-")),
});
```

An issue that carries a message is worded by it first, before any message map, so this is how one field gets its own sentence while a map words the rest. [Issues and messages](docs/errors.md) has the order.

## Working with results

### `formatIssue`, `flatten`, `formatPath` and `englishMessages`

`formatIssue(issue, messages)` turns an issue into text, `flatten(failure, messages)` groups the text of a failure by field for a form, and `formatPath(path)` writes a path as `user.addresses[0].street`. The text comes from the issue's own `message`, then a message map you pass, then "Invalid value". `englishMessages` is the built-in English map, a separate value so that a program that words its own issues does not bundle it. The wording of each code is also an export of its own, such as `invalidTypeMessage`, for a map of the few codes a program reports. [Issues and messages](docs/errors.md) explains all of them.

### `assert`

`assert(validator, input, { subject?, messages? })` returns the value the validator produced, or throws a `TypeError` naming the first issue, for input whose being invalid is a mistake of the caller, such as configuration. It accepts synchronous validators only. See [Throwing for invalid configuration](docs/errors.md#throwing-for-invalid-configuration).

### `Infer`

`Infer<typeof validator>` is the type a validator produces. It reads the output of either a synchronous or an asynchronous validator.

### `is`

`is(validator, input)` returns whether `input` passes, as a `boolean`. It does not narrow the type of `input`: a validator that trims, coerces or transforms produces another value than it was given, such as a number from the text `"5"`, so the input is not of the type the validator produces. Read `result.value` from calling the validator for that, or, where the validator keeps the value as it is, write the guard yourself: `(input: unknown): input is number => is(number(), input)`. It accepts synchronous validators only, and throws a `TypeError` if the validator turns out to return a promise.

### `pass` and `fail`

`pass(value)` and `fail(...issues)` build results, and are what a validator you write returns. See [Custom validators](docs/custom-validators.md).

### Types

The types you write in everyday use are `Validator<T>` and `AsyncValidator<T>`, `Check<T>` and `AsyncCheck<T>`, `Infer`, `ValidationResult<T>`, `ValidationIssue`, `Message`, `Messages` and the options interface of each validator, such as `StringOptions` or `UrlOptions`. The others exported, such as `Composed`, `Factory`, `Rest`, `InferShape` and `InferTagged`, are the machinery of the signatures: they are exported so that a validator you export from a library of your own can be named in its declarations. Each is documented in the source and listed in the [API reference](docs/reference/index.md).

<!-- Source: docs/changelog/0.3.0.md -->

# 0.3.0

0.3.0 makes the package a regular dependency and adds what its first adopter had written around it: a helper that throws for invalid configuration, wording for a few codes without the whole English map, and a validator for objects that are not plain. It also bounds what a failing input costs. Every signature of 0.2.0 is unchanged.

## Added

- `assert(validator, input, { subject, messages })`, which returns the value a validator produces or throws a `TypeError` naming the first issue and its path, as in `[I18n] locales[1]: Expected string, received number`, with the failure as the error's `cause`. It is for input whose being invalid is a caller's mistake, such as an options object; a form or a request is still read from the result. Its `messages` are optional, and it takes a synchronous validator only.
- `objectLike(shape, options?, ...checks)`, for any object that is not an array, such as an `Error` or another class instance, which `object` rejects. Each listed property is read as `input[key]`, so an inherited one, a non-enumerable one or a getter counts, and a property that throws while it is read is an `invalid_value` issue with `params.unreadable`, not an exception. The output is a new plain object of the listed properties.
- The English wording of each code as an export of its own: `invalidTypeMessage`, `tooSmallMessage`, `tooBigMessage`, `invalidFormatMessage`, `invalidValueMessage`, `invalidKeyMessage`, `unrecognizedKeyMessage`, `invalidIntersectionMessage` and `invalidUnionMessage`. A program that can report three codes passes a map of those three and bundles no other wording. Each is a function of the issue, so any of them can be called directly. `englishMessages` is the map of all of them.
- `nonBlank()`, a check that requires a string to hold a character that is not white space and leaves the string as it is, where `string({ trim: true, min: 1 })` trims it.

## Changed

- **Breaking.** A collection stops once its items have reported 1,000 issues. `array`, `tuple`, `set`, `map` and `record` validate no further item, cut their list there and add one `too_big` issue with `params` `{ maximum: 1000, type: "issues" }` at their own place, worded "Stopped after 1000 problems, so there may be more". An issue counts with the issues it holds in `params.issues`, as a `union`'s holds its options'. The unknown keys of a strict `object`, the repeats `unique` and `set` report, the keys a query string repeats and the conflicts of an `intersection` are cut the same way. Input that fails still fails and input that passes is unaffected; a program that must list every bad row of a long list validates the rows one at a time. A megabyte of bad items under a `union` took 5.9 s and 1 GB to report, and takes 71 ms and 25 MB.
- **Breaking.** An option that 0.1.0 had and 0.2.0 removed, such as `string({ pattern })`, throws `TypeError: Unknown option pattern`, as any name a factory does not read does, where 0.2.0 threw an error naming the replacement. The [0.2.0 changelog](docs/changelog/0.2.0.md#migrating-from-010) still maps each one.
- **Breaking.** An entry of a message map must be text or a function that returns text. `formatIssue`, `flatten`, `assert` and `standard` throw a `TypeError` for anything else, such as a translation lookup that returned `undefined`, where 0.2.0 returned it and a form showed `undefined` or `[object Object]`.
- **Breaking.** `englishMessages.invalid_intersection` is a function that returns "Conflicting values", as every other entry of the map is, where 0.2.0 held the text itself. Code that read that entry as text, such as to join it into a sentence of its own, calls it now; `formatIssue` and a map that spreads `englishMessages` are unaffected.
- Every option may be given as `undefined`, which is no option, under TypeScript's `exactOptionalPropertyTypes` too. `string({ max: limit })` with a `limit` that may be undefined was a compile error there, though the validator always read it as no limit.
- A primitive that ends a recursion no longer counts toward `lazy`'s `maxCalls`. A schema for any JSON value, whose arrays hold `lazy` items, rejected a valid list of 10,001 numbers in 0.2.0, and now accepts it. Objects and arrays count as before, and so does a primitive the recursion goes on through, such as text a `json` reads the next level from, or one whose result is pending, as under an asynchronous `check`.
- `englishMessages` words a `union` that a limit of `lazy` stopped with the limit, "Too complex to check within 10000 recursive steps", whatever its other options found, where 0.2.0 said "Does not match any of the allowed types" unless exactly one option was the one meant.
- The package is meant to be a regular `dependency` of a package that validates with it, where 0.2.0 recommended inlining it. A fix then reaches every adopter with one update, and an application carries the shared core once.

## Fixed

- `email`, `url` and `domain` no longer take time that grows with the square of a host's length. The runtime's URL parser decodes a punycode label that way, so `email()` took 0.3 s to reject `a@xn--` and 100,000 letters and 5 s for 400,000, in Node.js 24.19, however `url` was given the `xn--`: in capitals, in fullwidth letters or as an escape. A host written with more than 759 characters, three for each one a host can have, is now rejected before the parser reads it. A host that long was valid only when padded with characters the parser drops.
- `lazy` no longer slows with the square of the places one object is reached at. With `maxCalls` raised, 80,000 rows that shared ten objects took 7.8 s to validate, and take 0.2 s.
- A recursive `union` with many options no longer overflows the stack inside `lazy`'s default `maxDepth`. With twenty options before the recursive one, 3 kB of valid input nested 120 levels threw `RangeError: Maximum call stack size exceeded` in a fresh Node.js process and in Chromium, since every option that failed stayed on the stack while the next ran.
- Plain data that passes no longer pays for an exception thrown and caught inside the package. Asking whether a value is a `Date`, a `Map` or a `Set` throws for one that is not, and every option of a `union` that refused an object asked it, as `coerceDate` asked every text and number: 1 MB of valid `{}` under a `union` of four options took 8.7 s and takes 0.2 s, and `coerceDate` took 1.3 s to read 100,000 dates from text and takes 0.2 s. A primitive, an array, a plain object and an object with no prototype, as `querystring.parse` and `Object.groupBy` make it, are no longer asked when named, so a `Date` stripped of its prototype is named `object` in `received`; `date` still accepts it. A class instance is still asked, and so is any object given to `date`, `map`, `set` or `coerceDate`.
- A validator that returns no result is named. A factory given where its validator belongs, `object({ name: string })` for `string()`, is accepted when the schema is created, since it is a function, and threw `Cannot read properties of undefined (reading 'issues')` for an input without that property. A composer now throws `TypeError: The validator at ["name"] returned no result. A factory is called first, as in string()`, with the path written as a list, and a `union` names the option, `options[1]`. So does a validator written by hand that returns nothing, the value itself, or a failure without a list of issues. TypeScript already rejects each.
- `object` reads each validator of its shape once, when it is created. It read the property to check it and again to keep it, so a shape whose property is a getter could be checked as one value and run as another.

<!-- Source: docs/changelog/0.2.0.md -->

# 0.2.0

0.2.0 settles the API that 0.1.0 introduced. Every validator now takes its arguments in one order, its own, then options, then checks, and every one that reports an issue takes a `message`. The rarer constraints moved from options into checks you import only when you use them, and the helpers that build the built-in validators are public, so your own behave exactly as they do. Some 0.1.0 calls change; the [migration table](#migrating-from-010) maps each one.

## Added

- Checks: rules about a value that already has its type, given to a validator after its options, as in `string({ min: 3 }, startsWith("a"))`. A check runs once the value has its type and has passed the validator's own options, so `max` keeps a long string from a costly `pattern` or a lookup, for a format once the text is of the format, and for an object once every property has passed, so it can compare fields, and every check's issue is reported. The built-in ones are `pattern`, `startsWith`, `endsWith`, `includes`, `lowercase`, `uppercase`, `multipleOf`, `nonZero` and `unique`.
- `check(test, issue?)`, `format(name, test)` and `guard(expected, typeGuard)`, which build checks, string formats and validators for any type on the same internals as the built-in ones, with the same issue shapes, `message` option and checks. `check` takes its message as text or as a function of the issue, as a built-in check does. A check written by hand returns nothing or a list of issue objects, an issue without a `code` being `custom`, and anything else, such as `null` in the list or an issue whose `path` is `"confirm"`, throws a `TypeError`. The `Check`, `AsyncCheck` and `Message` types, and `Factory`, `Rest`, `AsyncRest` and `MessageOptions`, which name the arguments of a factory in its signature.
- A `message` option on every validator that reports an issue of its own, as text or a function of the issue, and a message argument on every built-in check, so one field can have its own sentence without a message map. The option also words what the validator's checks report without a message of their own, so `string({ message: "Invalid username" }, pattern(/^\w+$/))` never shows a user the pattern. A message function must return text, and the validator throws a `TypeError` when one returns anything else, such as a translation lookup that missed.
- Parts of `url` and `email`: `url({ credentials, host, port, path, query, repeated })` and `email({ domain, local })` take validators that check what the parser read. `credentials` receives `{ username, password }` as the parser writes them, or `undefined` when the URL names neither, and is the only way a URL with credentials, such as a `DATABASE_URL`, is accepted. `domain()` and `port()` validate those parts on their own, and `searchParams(validator)` reads a query string, or a `URLSearchParams` from any realm, into typed values. A repeated query key fails unless `repeated` is set. A part that fails is one `invalid_format` issue at the place of the URL or address, with `params` `{ format, part, issues }`, so a form shows it beside the field, and the format's `message` words it. With a `path` validator, a path holding an encoded `/` or `\` (`%2F` or `%5C`) fails before the validator runs, with `{ encodedSeparator: true }` inside the path part's issue, so `/api/..%2fadmin` cannot pass a check for `/api/` and reach `/admin` on a server that decodes the separator before routing. A path holding a segment `.` or `..` followed by `;` or `%3B`, such as `/api/..;/admin`, fails the same way, with `{ dotSegment: true }`, since a server that drops the parameters before resolving dot segments, as Tomcat and Jetty do, reads `..`, and a proxy that decodes the path, such as nginx, turns `%3B` into `;` on the way.
- Formats: `phone` (E.164), `slug`, `semver`, `jwt` (its structure), `creditCard` (the Luhn checksum), `cidr`, `mac`, `time` and `duration`, with `base64({ url: true })` for base64url, `uuid({ version })` for one version, and `datetime({ local: true })` for a date-time without a zone, such as an HTML `datetime-local` value.
- `symbol()`, and `oneOf` over a TypeScript `enum`.
- `optional(validator, value)`, which replaces a missing value with a default. A function is called to produce the default, so when the wrapped validator can produce a function, such as `func()`, the types accept only a function that returns it: `optional(func(), () => noop)`.
- `tuple(items, { rest, max })`: `max` caps the length of a tuple with `rest`, the fixed items included, so untrusted input can be bounded as an `array` is.
- English wording for every new format and check in `englishMessages`. A `union` no option accepted is worded with the first issue of the one option whose kind the input had, when there is exactly one, so `union([literal(""), email()])` given `"nope"` says "Invalid email address" rather than "Does not match any of the allowed types". An object that is not plain, such as `process.env`, is worded "Expected a plain object; copy it first, as in { ...value }" rather than "Expected object, received non-plain object".

## Changed

- **Breaking.** The package requires Node.js 24 or newer, where it declared 22. CI had only ever run it on 24, so the 22 claim was untested; it now names the line CI runs. Browser, worker and edge runtimes are unaffected. ([#203](https://github.com/codenhub/codenhub/issues/203))
- **Breaking.** Every factory throws a `TypeError` naming an option it does not read, such as `array(item, { maxx: 3 })` or `string({ mni: 2 })`, where 0.1.0 ignored it and left the limit it was meant to set unset. TypeScript already reported such a name, but not one passed through a loosely typed variable, read from configuration or written in JavaScript.
- **Breaking.** `url({ host })` gives a host validator, for `http`, `https` and the other schemes the URL Standard has rules for, only a host of lowercase letters, digits, `.`, `-` and `_`, an IPv4 address not written with a bare `0x` part, or an IPv6 address without a leading zero in its IPv4 tail, where 0.1.0's `allowLocal` passed `a*b.com`, which Firefox refuses and Chromium writes as `a%2Ab.com`, and passed in Chromium `a%20.com` and `[::01.2.3.4]`, which Node.js refuses.
- **Breaking.** `jwt()` rejects a token whose header or payload holds a key twice in any object, as in `{"alg":"none","alg":"HS256"}`, which JSON parsers read as different algorithms or claims.
- **Breaking.** A `record` whose key validator produces anything but text throws a `TypeError`, where a number became text and a symbol a key that `Object.keys` never lists. The types already forbade it.
- A composer whose child throws while another child's result is pending, as an asynchronous lookup is, handles the rejection of that result before the exception propagates, where it was left unhandled and could end a Node.js process.
- **Breaking.** `englishMessages` is frozen, so assigning to one of its codes throws in strict mode where 0.1.0 changed the wording for every user of it in the process. Spread it to override a code, as the docs show.
- `englishMessages` words a failed conversion with an article, "Cannot convert string to a number", where 0.1.0 said "to number". It prefixes an issue it quotes inside another with where it sits, as in "width: Must be at least 1" for a property of an object option of a `union`, and lists at most ten allowed values, then how many more there are, where 0.1.0 named no path and listed every value. A key given twice, by a `record` or `map` key validator that makes two keys the same or in a URL's query, says "Must be given only once", where 0.1.0 said "Invalid value".

- **Breaking.** `string`'s `pattern`, `startsWith`, `endsWith` and `includes` options are checks of the same names, and `number`'s `multipleOf` and `nonZero` are checks too. Their issues are unchanged, except that `multipleOf` reports `{ type: "number", format: "multipleOf", value }`, as the other number rules name themselves in `format`, where 0.1.0 reported `{ type: "number", multipleOf }`.
- **Breaking.** `string`'s `lowercase: true` and `uppercase: true` options are `case: "lower"` and `case: "upper"`. The `lowercase()` and `uppercase()` checks now require a case without changing the string.
- **Breaking.** `array`'s `unique` option is the `unique(by?)` check, and its callback is its first argument. Without `by`, its types accept only an array of primitives: every object or list a composer such as `object` produces is new, and two `Date`s of one moment are two objects, so `unique: true` on an array of objects, lists or dates found a repeat only where a validator passed the same object through twice, and those now need `by`, such as `unique((user) => user.id)`.
- **Breaking.** `url`'s `allowLocal` option is gone. A host validator replaces the rule that the host is a public domain: `url({ host: hostname() })` accepts any hostname, and `url({ host: unknown() })` any host the parser reads, as `allowLocal: true` did. A `mailto` recipient must now have a public domain whatever the options, where `allowLocal` accepted any hostname.
- **Breaking.** `ip()` produces an IPv6 address in its canonical form, lowercase with the longest run of zero groups shortened, so `0:0:0:0:0:0:0:1` is `::1`, with the IPv4 part of an IPv4-mapped or NAT64 address dotted, so `::ffff:c000:201` is `::ffff:192.0.2.1`. An IPv4 address is produced as written, as before. `cidr()` spells its address the same way, and `url()` gives its `host` validator an IPv6 host so, while the URL it produces keeps the hex groups the URL parser writes.
- **Breaking.** `invalid_type` names kinds of value and never a class: a class instance, a `Map` and a `Set` are `object`, where 0.1.0 named the class, `map` or `set`. A `Date` is `date` from any realm, an iframe's or a `vm` context's included.
- **Breaking.** `array` and `set` report a child that is not a function as `item`, where 0.1.0 named it `element`.
- **Breaking.** `isoDate()` reports `invalid_format` with `{ format: "isoDate" }`, the name of its validator as every other format reports, where 0.1.0 reported `"date"`.
- **Breaking.** `hostname()` and `uuid()` produce lowercase and `ulid()` uppercase, the canonical spelling of each, so one value is one string however it was written, where 0.1.0 produced them as written.
- **Breaking.** An option of the wrong type throws a `TypeError` when the validator is made, where 0.1.0 ignored it or read it as another value: a bound of `number` that is not a number or of `bigint` that is not a bigint, a `clamp` without a number `min` and `max`, a flag such as `int`, `trim`, `offset`, `url`, `repeated` or `allowPlus` that is not a boolean, a part validator of `url` or `email` that is not a function, `protocols` that is not a non-empty list, `unique(by)` with a `by` that is not a function, a `message` that is neither text nor a function, such as a group of translations that would reach a form as `[object Object]`, `oneOf` given text, which it read as a list of characters, an object or a function given to `literal` or `oneOf`, which equals only itself, text that is not text given to `startsWith`, `endsWith` or `includes`, such as a variable that is not set, which they required as `"undefined"`, a name that is not text given to `format` or `guard`, and an issue given to `check` that is neither text nor an issue, or whose `path` is not a list. `json(undefined)` throws too, where 0.1.0 read it as `json()` and accepted any JSON, and so do `object`, `partial` and `tagged` given a shape with a symbol key or one that is not enumerable, whose validator 0.1.0 never ran. `fail()` and `check()` throw for a `path` holding anything but keys and indexes, such as a symbol, which `formatPath` could not write. `json(undefined)` is a type error as well.
- **Breaking.** A size limit, `datetime`'s `precision`, `lazy`'s `maxDepth`, `multipleOf`'s step or a `date` bound that is not of its type at all, such as `string({ min: "3" })` or `date({ min: "2026-01-01" })`, throws a `TypeError`, where 0.1.0 threw a `RangeError`. A value of the right type that makes no sense, such as a negative size or an invalid `Date`, still throws a `RangeError`, and so does a `clamp` of `number` from `Infinity` or to `-Infinity`, which 0.1.0 accepted and which clamped every number to an infinity.
- **Breaking.** `lazy` limits the calls one validation makes, 10,000 by default and set with `maxCalls`, and fails past it with `too_big` and `{ maximum, type: "calls" }`. A schema whose work grows faster than its input, such as one that copies an object at every level of a recursive `union`, could run for hours on a few hundred bytes; 0.1.0 had no such limit. A `lazy` also gives the result it found for an object at a path again when the same object is reached there again in one validation, so the options of a recursive `union` of objects, which each reach the children, share their result, and its work grows with the input, where in 0.1.0 it doubled with each level; an asynchronous check under such a `lazy` runs once for each object and path. A validation is a call such as `schema(input)` and everything it reaches until its result settles, the work after an await included, so an asynchronous recursive schema is held to the limit too, and the items of an `array` share each `lazy`'s count. Each `lazy` counts its own calls against its own `maxCalls`, so no other `lazy` the validation reaches overrides it, and a validation made from a callback, such as a `check`'s test, has counts of its own. A `lazy` made while another's getter runs, as in a schema built anew at every level by a function its getter calls, counts with that one, so such a schema is bounded too. Recursive data with more nodes than that in one validation needs `maxCalls` raised.
- **Breaking.** `email()` and `url()` reject a domain whose top-level label is longer than 63 characters, which no domain name can have, where 0.1.0 accepted it, as `hostname()` already rejected it.
- **Breaking.** `url()` produces the host of a listed scheme the URL parser has no rules for, such as `ssh` or `postgres`, in lowercase with its escapes in uppercase, and gives it so to a `host` validator, where 0.1.0 produced it as written, so two spellings of one URL were two values.
- **Breaking.** `tagged` throws a `TypeError` when its key is not text, its variants are not a plain object or it has no variant, `standard` when its message map is not an object, `union` when its options and `tuple` when its items are not a list, and `tuple` when it has no item, where 0.1.0 named the property `"undefined"`, took a list's indexes for tags, rejected every value, worded every issue "Invalid value", threw that the value is not iterable, or made a `tuple` that accepts only the empty list. The types already forbade each.
- **Breaking.** `is(validator, input)` returns a `boolean` and no longer narrows the type of `input`, where 0.1.0 typed it `input is T`. The narrowing was false for every validator that produces another value than it was given: `is(coerceNumber(), "5")` typed the text as a number, and `is(json(schema), text)` a string as an object. Read `result.value` from calling the validator, or write the guard yourself where the validator keeps the value: `(input: unknown): input is number => is(number(), input)`.
- `intersection` merges `0` and `-0` as `0`, where 0.1.0 reported them as `invalid_intersection`.
- **Breaking.** `multipleOf` reads a whole number beyond `Number.MAX_SAFE_INTEGER` as it is written, as it reads a fraction, where 0.1.0 compared the double it is held as. So `1e23` is a multiple of `10`, which 0.1.0 rejected, since the double is `99999999999999991611392`, and `2 ** 60`, written `1152921504606847000`, is not one of `1024`, which 0.1.0 accepted.
- **Breaking.** `base64()` rejects the empty string, as `hex()` does, where 0.1.0 accepted it as the encoding of no bytes.
- **Breaking.** A factory throws a `TypeError` when its first argument is neither options nor a check, such as `string("abc")`, which 0.1.0 read as options, or `array(item, [unique()])`, whose checks in a list would be dropped, and when its options are an object that is not a plain one, such as `string(/^a/)` where `string(pattern(/^a/))` was meant, which 0.1.0 read as options and ignored, accepting every string. `object` and `partial` throw one for a shape that is a list. The types already forbade each.
- `standard(validator)` returns a `Promise` from `~standard.validate` for any asynchronous validator, where a validator of your own that returned another kind of thenable had it returned as it was, which a caller testing for `instanceof Promise` read as a result without issues.
- `coerceDate` reads a time without seconds, `2026-09-28T14:30+02:00`, as an HTML `datetime-local` input writes the time.
- **Breaking.** `coerceDate` rejects a date-time without a zone, such as `2026-09-28T14:30`, unless `coerceDate({ zoneless: "utc" })` says to read it as UTC, where 0.1.0 read it as UTC always. Such text names a time on some clock, so a `datetime-local` value from a user three hours west of UTC became a moment three hours early, without a word. A date alone is still midnight UTC.
- `array`, `tuple`, `instanceOf` and `searchParams` reject an input that throws when inspected, such as a revoked `Proxy`, as the wrong type instead of throwing, as the other validators already did, `intersection` reports one that a side passed through as a conflict instead of throwing, and `instanceOf` throws when it is made for a function `instanceof` cannot test against, such as an arrow function.
- **Breaking.** A `mailto` URL fails when its `subject` holds an encoded line break (`%0D` or `%0A`), which RFC 6068 expects in `body` alone and which would let a mail program that writes the subject unescaped read a header such as `Bcc:` after it, and when `subject` holds any other encoded control character, such as `%00` or `%09`, or a line or paragraph separator, `%E2%80%A8` or `%E2%80%A9`, since a subject is one line, and when `subject` or `body` appears more than once, which leaves the one a mail client uses to the client. 0.1.0 accepted all of them. `to`, `cc` and `bcc` may still repeat.
- **Breaking.** `domain()`, `hostname()`, `email()` and `url()` give the same answer for an internationalized domain name in every runtime, the one browsers give. A punycode label must decode to the label it spells, where Chromium and WebKit accepted `xn--zz.com`, and a name with a right-to-left label must keep the bidi rule (RFC 5893), where Node.js accepted `٠.com` and `1.ب`. A host Node.js writes as a label it cannot read again, such as `éxn--.com`, which becomes `xn--xn---9oa.com`, is rejected everywhere. `url` holds the host to these rules whatever its `host` validator, which replaces only the rule that the host is public.
- **Breaking.** `email()` and `url()` treat every name under `.arpa`, such as `1.0.0.127.in-addr.arpa`, and the eleven IDN test top-level domains IANA reserved, such as `.テスト` (`xn--zckzah`), as special-use names, which are not public hosts, where 0.1.0 rejected only `home.arpa` among them. They treat `.home`, `.corp` and `.mail`, which ICANN resolved never to delegate, `.localdomain`, as in `localhost.localdomain`, and Kubernetes' `.svc` and `.cluster`, as in `kubernetes.default.svc`, a known target of forged requests inside a cluster, as special-use names too. `domain()` applies the same rule.
- **Breaking.** `hostname()` rejects an absolute name ending in a dot, such as `example.com.`, where 0.1.0 accepted it and kept the dot, and `url()` rejects such a host whatever its host validator, where 0.1.0's `allowLocal` accepted `http://localhost./`. The dot names the same host, so it was a second spelling of one host that passed a check comparing the value as a string, such as a list of blocked hosts. `domain()` and `email()` reject it too.
- **Breaking.** `fail()` throws a `TypeError` for an issue whose `path` is not a list, as `check()` already did, where 0.1.0 accepted it and a composer then split text such as `"confirm"` into one segment per letter, and for an issue whose `code` or `message` is not text, such as `fail({ code: 5 })`, which 0.1.0 put in the issue as it was. The types already forbade both.
- **Breaking.** `formatPath`, and so the field names of `flatten`, quotes a key holding a control character or a line separator, with them escaped, as `["a\nb"]`, where 0.1.0 wrote the line break into the path, so a key the sender chose could break or forge a log line. A segment that is neither text nor a number, which only a validator written by hand can put in a path, is written as `String` writes it, where 0.1.0 threw.
- **Breaking.** `coerceNumber` rejects text holding a nonzero decimal too small for a double, such as `0.` followed by 400 zeros and a `1`, where 0.1.0 read it as `0`, the value `nonZero()` and a divisor treat apart.
- **Breaking.** `oneOf` produces the value it lists for an input `===` matches, so `oneOf([0])` produces `0` for `-0`, where 0.1.0 produced the input, as `literal` already did. It throws a `TypeError` for a list with a hole, `[, "a"]`, which 0.1.0 read as `undefined` and then accepted, and for a `Set`, which its types never accepted and 0.1.0 read as a list.
- `object` builds its output from the input as it was when called, reading every property before any property's validator runs, where 0.1.0 read each property only when its turn came and, with an asynchronous property, read the keys `passthrough` copies, and whether a property is present, again once the property had settled, so a change made to the input by a callback or meanwhile reached the output unchecked.
- **Breaking.** `fallback`'s types accept only a function that returns the fallback when the wrapped validator can produce a function, such as `func()`, so `fallback(func(), () => noop)` and not `fallback(func(), noop)`, which 0.1.0 accepted and which called `noop` with the issues and used what it returned. Behavior is unchanged.
- `record` reads every value when called, before any key or value validator runs, where 0.1.0 read a value only once its key had passed, so with an asynchronous key a change made to the input meanwhile reached the output unchecked, as `object` did.
- **Breaking.** `url()` rejects port 0, `https://example.com:0/`, which nothing can connect to and which `port()` rejects, where 0.1.0 accepted it. A `port` validator replaces the rule, as a `host` validator replaces the public-host one.
- A composer reports an issue deep inside recursive input in time that grows with the input, where 0.1.0 copied the path of every issue at every level, which cost the square of the depth for each: 400 KB of input nested 100 levels deep with 100,000 issues took about eight seconds, and takes about half a second. Paths are unchanged.
- **Breaking.** `formatIssue(issue, messages)` and `flatten(failure, messages)` require a message map, and throw a `TypeError` without one, where 0.1.0 worded every issue "Invalid value" when it was left out, which a form showed without a word. Pass `englishMessages`, a map of your own, or `{}` when every issue carries its own `message`.
- **Breaking.** An issue holds no bigint: the bounds of `bigint` and `coerceBigint`, the value `literal` expects and the options of `oneOf` are written as their decimal digits, `"10"` for `10n`, with `type: "bigint"` beside a bound, a `literal` of a bigint and a `oneOf` of bigints alone, where 0.1.0 held the bigint itself, on which `JSON.stringify` throws, so an API that sent its issues as JSON failed on them. Read a bound back with `BigInt(params.minimum)`.
- **Breaking.** `optional(validator, value)` and `fallback(validator, value)` take a primitive or a function, and an object or an array only from a function, such as `() => []`, where 0.1.0's `withDefault` and `fallback` took any value and gave every result the same object, so a change to one showed up in the next. The types reject it, and both throw a `TypeError` when given one from JavaScript.
- **Breaking.** An issue held in another issue's `params.issues` keeps the issues behind it only when none of them carries issues of its own, where 0.1.0 nested them in full. So a `union` that fails inside an option of another `union`, as one for a property of an object option does, is held with its code and path, without its options' issues, and so is a union inside a failed part of a URL or inside a bad key. A limit of `lazy` that stopped the validation behind such an issue stands in its place as the `too_big` issue it is. In a recursive union every option reaches the same children, so the full nesting wrote those issues out once for every path through it: 430 bytes of input made a result of 350 MB of JSON, and an asynchronous recursive schema nested them 10,000 levels deep, which `JSON.stringify` could not write.
- **Breaking.** `StandardSchemaV1<TInput, TOutput>` defaults `TOutput` to `TInput`, as the specification does, where 0.1.0 defaulted it to `unknown`, so `StandardSchemaV1<User>` is a schema of users. A type written with one argument that meant an output of `unknown` names it now: `StandardSchemaV1<unknown, unknown>`.

## Removed

- **Breaking.** `refine`: a check given to the validator does the same and reports its issue with the others. `refine` checks written as type guards narrowed the output type, which a check does not. Its issue type, `RefineIssue`, goes with it: a check takes an `IssueInput`.
- **Breaking.** `withDefault`: use `optional(validator, value)`.
- **Breaking.** `nativeEnum`: use `oneOf(Enum)`.
- **Breaking.** `discriminatedUnion` is renamed `tagged`, with the same behavior, and its type `InferDiscriminated` is `InferTagged`.

## Migrating from 0.1.0

Everything not listed carries over unchanged. A removed option of `string`, `number`, `array` or `url` throws a `TypeError` naming its replacement when the validator is made, rather than being ignored, so a call that still passes one, through a variable or from JavaScript, fails at once instead of accepting what it was written to reject.

| 0.1.0                                                        | 0.2.0                                                                           |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| `string({ pattern: re })`                                    | `string(pattern(re))`                                                           |
| `string({ min: 3, startsWith: "a" })`                        | `string({ min: 3 }, startsWith("a"))`                                           |
| `string({ endsWith: s })`, `string({ includes: s })`         | `string(endsWith(s))`, `string(includes(s))`                                    |
| `string({ lowercase: true })`                                | `string({ case: "lower" })`                                                     |
| `string({ uppercase: true })`                                | `string({ case: "upper" })`                                                     |
| `number({ multipleOf: 5 })`                                  | `number(multipleOf(5))`                                                         |
| `number({ int: true, nonZero: true })`                       | `number({ int: true }, nonZero())`                                              |
| `array(item, { max: 5, unique: true })`                      | `array(item, { max: 5 }, unique())`                                             |
| `array(item, { unique: (user) => user.id })`                 | `array(item, unique((user) => user.id))`                                        |
| `refine(validator, test, issue)`                             | `validator(check(test, issue))`, such as `string(check(test, issue))`           |
| `refine(object(shape), test, issue)`                         | `object(shape, check(test, issue))`                                             |
| `refine(object(shape, options), test, issue)`                | `object(shape, options, check(test, issue))`                                    |
| `withDefault(validator, value)`                              | `optional(validator, value)`                                                    |
| `nativeEnum(Enum)`                                           | `oneOf(Enum)`                                                                   |
| `formatIssue(issue)`, `flatten(failure)`                     | `formatIssue(issue, englishMessages)`, `flatten(failure, englishMessages)`      |
| `coerceDate()` given a date-time without a zone, read as UTC | `coerceDate({ zoneless: "utc" })`, or add the zone to the text                  |
| `discriminatedUnion(key, variants)`                          | `tagged(key, variants)`                                                         |
| `url({ allowLocal: true })`                                  | `url({ host: hostname() })`, or `url({ host: unknown() })` for IP addresses too |
| `refine(unknown(), (value) => typeof value === "function")`  | `func()`                                                                        |
| `hostname()` given an absolute name such as `example.com.`   | `pipe(transform(string(), (name) => name.replace(/\.$/, "")), hostname())`      |

A `refine` that wrapped a validator which had no place for checks, such as `refine(optional(string()), test)`, moves the check onto the inner validator: `optional(string(check(test)))`. A check runs only on a value of its type, so it never sees the `undefined` that `optional` lets through.

<!-- Source: docs/changelog/0.1.0.md -->

# 0.1.0

0.0.1 validated a value the moment you called `val.string(value).minLength(3)`. 0.1.0 is a rewrite around one idea: a validator is a function that takes any input and returns a result, and every validator is its own import. A package or app that needs an email check bundles an email check and nothing else. Nothing from 0.0.1 carries over unchanged; the [migration table](#migrating-from-001) maps each 0.0.1 call to its replacement.

## Added

- Validators as plain functions, created by calling a factory and then called with the value: `email()(input)`, `number({ int: true, min: 0 })(input)`. Any function with that shape is a validator, so your own need no helper.
- Leaves: `string`, `number`, `bigint`, `boolean`, `date`, `literal`, `oneOf`, `nativeEnum`, `unknown`, `never`, `func` and `instanceOf`. `null` and `undefined` are `literal(null)` and `literal(undefined)`.
- String formats, each its own validator: `email`, `url`, `uuid`, `ip`, `datetime`, `isoDate`, `hostname`, `hex`, `base64`, `ulid`, `nanoid` and `cuid2`.
- Collections: `array` (with `min`, `max`, `length` and `unique`), `tuple` (with `rest`), and `record`, `set` and `map` (with `min`, `max` and `length`).
- Combinators: `object`, with `unknownKeys` of `"strip"`, `"strict"` or `"passthrough"`, and `partial` to make every property of a shape optional; `optional`, `nullable`, `nullish`, `withDefault`, `fallback`, `pipe`, `transform` and `refine`, including rules and conversions that return a promise, and `refine` checks written as type guards, which narrow the output type.
- Choosing and merging: `union`, `discriminatedUnion`, which takes a record of validators keyed by tag, and `intersection`. `lazy` for recursive data, which stops at a depth limit, 128 levels by default, and fails with `too_big` instead of overflowing the stack, and `json` for text holding JSON.
- Coercing validators for text input: `coerceString`, `coerceNumber`, `coerceBoolean`, `coerceBigint` and `coerceDate`, taking the options of their strict counterparts.
- Results that are plain data: `{ ok: true, value }` or `{ ok: false, error: { issues } }`, built with `pass` and `fail`. Bad input never throws, unless it carries code of its own, such as a getter, that throws while it is read.
- Issues with a stable `code`, a `path` to the offending value and `params` describing the failure. New codes are `unrecognized_key`, `invalid_key`, `invalid_union` and `invalid_intersection`.
- `formatIssue`, `flatten` and `formatPath` to turn issues into text and group them by field for forms. Text comes from a message map you pass, `englishMessages` for the built-in English or one of your own to reword or localize it, so a program that words its own issues does not bundle the English.
- `standard(validator, messages)`, which exposes a validator as a Standard Schema v1.
- `is`, a type guard for synchronous validators, and `Infer` to read the type a validator produces.
- Asynchronous validation tracked in the types: a validator holding a rule that returns a promise is an `AsyncValidator`, and so is everything that contains it, so the compiler makes you `await` it.
- Types for object shapes, collections and results, so validators can be annotated and reused: `Validator`, `AsyncValidator`, `InferShape`, `InferTuple`, `InferRecord` and the rest listed in the [reference](docs/validators.md#types).
- A generated [API reference](docs/reference/index.md) ships with the package, listing every export with its signature and documentation.

## Changed

- **Breaking.** Validators are created first and called later, and every one is a named import. The `val` and `coerce` namespaces are gone: `val.string(x).minLength(3)` is `string({ min: 3 })(x)`.
- **Breaking.** A failed result holds `error.issues`, a list of every issue found, including for a single failure, where 0.0.1 held one error with optional child issues. `error` is plain data and not an `Error`.
- **Breaking.** An issue has `code`, `path`, and `params` for the facts behind a failure. It has no `message`, `input`, `expected` or `received`: the expected and received types are in `params`, and text comes from `formatIssue`. Issues never contain the input, and the `includeInput` and `path` options are gone; objects and collections produce paths themselves.
- **Breaking.** Rules never rewrite the value unless you ask. `email()` no longer trims the input, `url()` no longer adds `https://`, and `notEmpty` no longer trims by default. `trim`, `lowercase`, `uppercase` and `clamp` are the options that change a value, and they run before the constraints, so a trimmed string is what the constraints see.
- **Breaking.** `email()` and `url()` produce what the URL parser reads, so a check made later on the value sees what a mail server or a request will: `email()` keeps the local part as written and gives the domain in lowercase ASCII, with internationalized labels in punycode, and `url()` gives the URL as the parser writes it, with dot segments resolved, the host lowercased and characters such as `"` percent-encoded. Text holding whitespace or control characters is rejected rather than cleaned.
- **Breaking.** `url()` requires a scheme, accepts `http` and `https` unless `protocols` says otherwise, and rejects embedded credentials and non-public hosts; `allowLocal: true` accepts `localhost`, single-label hosts, IP addresses and special-use names such as `db.internal`. `email()` and `url()` still accept public host names only. Listed `mailto`, `tel` and `urn` URLs are checked by their own rules, even when written with a host, a `mailto` query may hold only `to`, `cc`, `bcc`, `subject` and `body`, its recipients are produced as `email()` produces them and its `subject` and `body` hold only the characters RFC 6068 allows, plus `/` and `?`, a `tel` parameter holds only the characters RFC 3966 allows, other schemes without a host are rejected, and `javascript`, `vbscript` and `data`, whose URLs run script, cannot be listed.
- **Breaking.** A missing property is `invalid_type` at the property's path, with `received: "undefined"`, where 0.0.1 reported `missing_key`. `NaN` and the infinities are `invalid_type` for numbers, where infinities were `invalid_value`.
- **Breaking.** Invalid options, such as `string({ min: -1 })`, throw a `RangeError` when the validator is created, where 0.0.1 returned an `invalid_value` failure on every call. So does a combinator given a child that is not a function, or `instanceOf` a target that is not one, with a `TypeError` naming it, and a `union` or `pipe` given none, and a `oneOf` or `nativeEnum` given no value, or `NaN`.
- **Breaking.** Coercion failures are `invalid_type` with `coerced: true` in `params`, where 0.0.1 used `invalid_format`, and they never repeat the input.
- **Breaking.** Exceptions thrown by your own callbacks propagate, where 0.0.1's `custom` turned a thrown value into a failure. A callback that throws is a bug, and invalid input is a `fail(...)` result.

## Removed

- `val`, `coerce`, `custom`, `ok`, `err` and `parseResult`, and the types `ValidationError`, `ValidationErrorCode`, `ValidationErrorInput`, `ValidationErrorOptions`, `ValidationOptions`, `StringValidators`, `NumberValidators`, `ObjectValidators`, `ArrayValidators` and `PlainObject`. `ValidationOk`, `ValidationErr`, `ValidationResult`, `ValidationIssue` and `ValidationPathSegment` remain, with new shapes. Build results with `pass` and `fail`.
- The string, number, array and object methods of 0.0.1 as methods: `range`, `port`, `finite`, `positive` and its siblings, `minLength`, `maxLength`, `notEmpty`, `matches`, `plain`, `fileType` and `hasKeys`. The migration table shows each replacement. `fileType` and `hasKeys` have no dedicated validator and are one `refine`.

## Migrating from 0.0.1

Every call changed. The table maps each 0.0.1 call to its replacement. Where `x` is the value being checked, a 0.1.0 call reads `validator(options)(x)`: create the validator, then call it.

| 0.0.1                                             | 0.1.0                                                                                                                 |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `val.string(x).email()`                           | `email()(x)`                                                                                                          |
| `val.string(x).url({ forceHttps: true })`         | `url({ protocols: ["https"] })(x)`                                                                                    |
| `val.string(x).minLength(3, { trim: true })`      | `string({ trim: true, min: 3 })(x)`                                                                                   |
| `val.string(x).maxLength(30)`                     | `string({ max: 30 })(x)`                                                                                              |
| `val.string(x).notEmpty()`                        | `string({ trim: true, min: 1 })(x)`                                                                                   |
| `val.string(x).matches(re, message)`              | `string({ pattern: re })(x)`, with the message chosen where you show it                                               |
| `val.string(x).fileType(["png", "jpg"])`          | `refine(string({ lowercase: true }), (name) => ["png", "jpg"].includes(name.split(".").pop() ?? ""))(x)`              |
| `val.number(x).range({ min: 1, max: 9 })`         | `number({ min: 1, max: 9 })(x)`                                                                                       |
| `val.number(x).port()`                            | `number({ int: true, min: 1, max: 65535 })(x)`                                                                        |
| `val.number(x).positive()`, `.negative()`         | `number({ gt: 0 })(x)`, `number({ lt: 0 })(x)`                                                                        |
| `val.number(x).nonNegative()`, `.nonPositive()`   | `number({ min: 0 })(x)`, `number({ max: 0 })(x)`                                                                      |
| `val.number(x).nonZero()`, `.int()`, `.safeInt()` | `number({ nonZero: true })(x)`, `number({ int: true })(x)`, `number({ safeInt: true })(x)`                            |
| `val.number(x).finite()`                          | `number()(x)`, which always rejects `NaN` and the infinities                                                          |
| `val.array<T>(x).minLength(1)`, `.maxLength(5)`   | `array(item, { min: 1 })(x)`, `array(item, { max: 5 })(x)`                                                            |
| `val.object(x).plain()`                           | `record(string(), unknown())(x)`                                                                                      |
| `val.object(x).hasKeys(["id"])`                   | `refine(record(string(), unknown()), (data) => Object.hasOwn(data, "id"), { code: "missing_key", path: ["id"] })(x)`  |
| `custom(x, fn, { path })`                         | a validator function returning `pass(value)` or `fail({ message, path })`, or `refine(unknown(), check, { path })(x)` |
| `coerce.int(x)`                                   | `coerceNumber({ int: true })(x)`                                                                                      |
| `coerce.number(x)`, `coerce.string(x)`            | `coerceNumber()(x)`, `coerceString()(x)`                                                                              |
| `coerce.bool(x)`                                  | `coerceBoolean()(x)`                                                                                                  |

Reading a result also changed. Where 0.0.1 returned `{ ok, value }` or `{ ok, error }` with a single failure, 0.1.0 returns every issue in `result.error.issues`:

```ts
import { englishMessages, formatIssue, string } from "@codenhub/validation";

const result = string({ min: 3 })(input);

if (!result.ok) {
  formatIssue(result.error.issues[0], englishMessages); // was: result.error.message
}
```
