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

# @codenhub/validation

Validation for data you do not control: a request body, a query string, a form, a message from another origin. A validator gives you either the typed value or every reason it is not one, and is built for input that may be hostile:

- **Formats return what a parser read.** `email()` and `url()` produce what the platform's URL parser sees, so the value you store is the one a request or a mail server will use.
- **What a failing input costs is bounded.** A long list of bad items stops at 1,000 issues, and input nested too deep is one issue, never a stack overflow.
- **Issues never hold a value of the input**, so logging a failed validation cannot log a password. They do name its keys, in `path`, as [The issue](docs/errors.md#the-issue) says.

Each validator is a function you import on its own, so a program ships only what it uses: a lone `boolean()` is 1.8 kB gzipped, and an object of a string, an email and a number 6.5 kB. No dependencies.

> **Experimental:** pre-1.0. The API of 0.4.0 is meant to hold for every 0.4.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), which also has the migrations from earlier versions.

## Installation

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

## Usage

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

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

const result = email()(input);

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

Build objects from validators, and read the type from the validator:

```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);
```

`signup` is also a [Standard Schema](docs/standard-schema.md), so a form library or a router that takes one takes it as it is.

A failed result lists every problem in `result.error.issues`, each with a `code`, a `path` and `params`. `formatIssue` words one, and `flatten` groups them by field for a form, in English, in Portuguese with `portugueseMessages`, or in a message map of your own. For input that is a caller's mistake, such as an options object, `assert` returns the value or throws a `TypeError` naming the first problem.

Every validator takes options, then checks for the rarer rules, and a `message` of its own:

```ts
import { checkFields, 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(),
  },
  checkFields(["password", "confirm"], (data) => data.password === data.confirm, {
    path: ["confirm"],
    message: "Passwords must match",
  }),
);
```

`checkFields` runs as soon as the fields it names pass, so a form shows it beside the other fields' problems. `check`, `format` and `guard` build rules, formats and validators of your own, which behave as the built-in ones do.

## What hostile input gets

Each line below is run against the built package by the test suite, with the result its comment states:

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

// A million bad items give 1,001 issues: a thousand of them, and one saying the list stopped there.
const flood = array(number())(Array.from({ length: 1_000_000 }, () => "x"));
String(flood.ok || flood.error.issues.length); // "1001"

// Input nested 100,000 deep gives one issue, where recursion without a limit overflows the stack.
interface Tree {
  children: Tree[];
}
const tree: Validator<Tree> = object({ children: array(lazy(() => tree)) });
let deep: Tree = { children: [] };
for (let level = 0; level < 100_000; level += 1) {
  deep = { children: [deep] };
}
tree(deep); // { ok: false, ... }, code "too_big"

// An address a server must not fetch is not a URL here.
url()("http://169.254.169.254/latest/meta-data"); // { ok: false, ... }
url()("http://localhost:3000"); // { ok: false, ... }
url()("javascript:alert(1)"); // { ok: false, ... }

// A password that failed is not in what you log.
const login = object({ password: string({ min: 12 }) })({ password: "hunter2" });
String(JSON.stringify(login).includes("hunter2")); // "false"

// A key that would become the prototype of a copy of the output is refused.
record(string(), unknown())(JSON.parse('{"__proto__":{"admin":true}}')); // { ok: false, ... }, code "invalid_key"
```

## 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)
- [JSON Schema](docs/json-schema.md)
- [Integrations](docs/integrations.md)
- [Changelog](docs/changelog/index.md)

## Requirements

- Node.js 24 or newer, or a current browser, worker or edge runtime. `url()`, `email()`, `domain()` and `hostname()` need `URL.canParse`, which 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`; CommonJS code can `require()` it on every supported Node.js version.
- TypeScript 5.0 or newer, for the types, which use `const` type parameters.

## Notes

- **Invalid input never throws.** A bad option, such as `string({ min: -1 })`, throws when the validator is created. Exceptions from your own callbacks propagate, and so do those of a getter or a `Proxy` trap in the input, except under `objectLike`. [Reading the result](docs/index.md#reading-the-result) has the details.
- **Values change only when you ask**, with `trim`, `case` or `clamp`, or when a format has several spellings of one value: `email()` and `url()` produce what the URL parser read, and `ip()`, `uuid()`, `phone()` and the rest produce a canonical spelling. See [Formats](docs/validators.md#formats).
- **`email()` and `url()` accept public host names only**, not `localhost`, IP addresses or names such as `db.internal`. `url({ host: hostname() })` accepts any host; neither resolves the name. See [`url`](docs/validators.md#url).
- **Validation is synchronous until a rule returns a promise**, and the types then say the result must be awaited. See [Sync and async](docs/index.md#sync-and-async).
- **Input that passes is checked in full.** Cap the size of untrusted input, give every collection a `max`, and use `audit(validator)` in a test to list what a schema leaves open. A message received by structured clone, such as from `postMessage`, can hold millions of items in a few bytes, which only `max` bounds. See [Collections](docs/validators.md#collections), [`lazy`](docs/validators.md#lazy) for the limits of recursive schemas, and [Checking a schema for the bounds it needs](docs/validators.md#checking-a-schema-for-the-bounds-it-needs).

## 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.

The package is built for that boundary, where the input may be hostile. `email()` and `url()` return what the platform's URL parser read, so the value you store is the one a request or a mail server will use. What a failing input costs is bounded: a long list of bad items stops at 1,000 issues, and input nested too deep is one issue and never a stack overflow, while input that passes is checked in full, so give collections a `max`. An issue the package reports never holds the value that failed, so a log of failures holds no passwords; it does name the input's properties, in `path`, and a check you write decides what its own issue holds.

## 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, 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));
  }
}
```

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.
- `brand(validator, name)` marks the type a validator produces, so only a validated value is accepted where that type is asked for, and `readonly(validator)` makes what it produces read-only in its type and freezes the object or array the validator made.
- `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, given to the validator after its options, and `checkFields(keys, test, issue)` one across properties of an object, such as two fields having to match, that runs as soon as those properties have passed, 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 its input, so the guard above names the type itself. A type guard also says that a value that fails is not of the type, which is false of a validator: `string({ min: 3 })` refuses `"ab"`, which is a string. Read `result.value` from calling the validator where you want the typed value. `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.
- [JSON Schema](docs/json-schema.md): write a validator as a JSON Schema, for an HTTP API, the tools of a language model or a form generator.
- [Integrations](docs/integrations.md): use a validator with react-hook-form, TanStack Form, tRPC, Hono, the AI SDK, the Model Context Protocol and OpenAPI.
- [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 rule across fields that does not wait for the others

A check on an object is given the whole object, typed, so it can run only once every property has passed. On a form that is often too late: with a name still empty, the check above says nothing about the passwords, and the user learns they do not match only after fixing everything else.

`checkFields(keys, test, issue?)` is a check that names the properties it needs. It runs once those have passed, whether the others did or not, and its test is given an object of those properties alone. A validation still gives one result, so where another property waits, as one that asks a server does, the test runs and its issue comes with the rest, once every property has answered:

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

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

signup({ name: "", password: "correct horse", confirm: "nope" });
// fails with the issue of `name`, and "Passwords must match" at `confirm`
```

- It still waits for the properties it names: while `password` is too short, nothing is said about `confirm`, since the test would compare a value that is not valid.
- Its issues come after those of the properties, in the order the checks were given.
- `data` holds the named properties and no other, so the test cannot come to rely on one that may have failed. A property of the list that is absent, as an optional one may be, is absent from `data` too.
- It takes its issue as `check` does, and a test that returns a promise makes the object asynchronous.
- A name the object does not have is a `TypeError` when the object is created, since it is a misspelling. So is a list of no names, when the check is created.

It works on `object` and `objectLike`. Keep `check` for a rule that needs the whole object, and use both on one object when you have both kinds.

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 second type says what it accepts, for [`InferInput`](docs/validators.md#inferinput): `Validator<number, string>` is a validator that reads a number from text. Without it the input type is `unknown`. 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, `reserved: true` inside the `invalid_key` of a `__proto__` key, `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, its key validator turned it into a key an earlier entry already has, or the key is `__proto__`, in a `record` or among the unlisted keys of a passthrough `object`.                  | `issues`: what the key validator found, with paths relative to the key, for a repeated key one `invalid_value` issue with `unique: true`, or for `__proto__` one with `reserved: 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, so text is a choice you make where you show it. The one exception is a validator's own [Standard Schema](docs/standard-schema.md), which a library that takes one calls, and which words each issue in short English. `formatIssue` builds it from a message map, `englishMessages` unless you pass another. 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 { formatIssue, number } from "@codenhub/validation";

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

The English wording is `englishMessages`, a value of its own, about 1.7 kB gzipped. A program that calls `formatIssue` or `flatten` bundles it, even when it passes another map, since it is their default; one that never shows an issue, or checks its options with `assert` and wording of its own, does not. Every validator carries the short English of its own Standard Schema, about 0.4 kB, which is not this map. A program whose issues all carry their own `message` passes `{}`, and a map that is not an object, such as `null`, is a `TypeError`.

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, the second argument, `englishMessages` when you pass none.
3. The entry `default` of that map, which words a code the map has no entry for.
4. 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.

### Built-in languages

Two maps ship with the package, each a value of its own, so a program bundles the languages it imports and no other:

| Map                  | Language                         | `number({ min: 18 })(15)` |
| -------------------- | -------------------------------- | ------------------------- |
| `englishMessages`    | English                          | "Must be at least 18"     |
| `portugueseMessages` | Portuguese, as written in Brazil | "Deve ser no mínimo 18"   |

Both word the same nine codes and are used the same way, in `formatIssue`, `flatten`, `assert` and `standard`. For a program in several languages, choose the map where you show the issue:

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

const maps: Record<string, Messages> = { en: englishMessages, pt: portugueseMessages };
const wordingFor = (locale: string): Messages => maps[locale] ?? englishMessages;

formatIssue(issue, wordingFor("pt"));
```

What a map does not translate is what the issue holds as data: the names of types, as in "Esperado string, recebido number", the name of a format the package does not know, and the name of a part of a URL or an address. An issue whose code the map lacks, such as a custom check's, is worded by the map's `default` entry, "Valor inválido" in `portugueseMessages`. `englishMessages` has none, since `formatIssue`'s own last wording, "Invalid value", is English already.

### 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 into a language that has no map here, write a map of your own. Cover the codes your validators can report, which the tables above list, and give it a `default` entry, text or a function of the issue, for every code you leave out, which would otherwise say "Invalid value" in English:

```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",
  default: "Valor inválido",
};

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, its `default` entry included, 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 { flatten } from "@codenhub/validation";

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

It takes the same message map as `formatIssue`, `englishMessages` unless another is given. 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 failed result is plain data, so it can be logged, sent to a client or stored, and because no issue the package reports holds a value of the input, doing so does not leak the values that were submitted; an issue does name the input's properties, in `path`, and a check you write decides what its own issue holds. A result that passed holds the value itself. 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/integrations.md -->

# Integrations

A validator goes where a library asks for a schema in one of three ways: as a [Standard Schema](docs/standard-schema.md), which most form libraries and frameworks accept; as a [JSON Schema](docs/json-schema.md), which tools of language models and OpenAPI documents are written in; or called directly, as any function is, which is also how one field of a [zod or valibot schema](#inside-a-zod-or-valibot-schema) can use it. Every validator is a Standard Schema as it is, worded in short English; the examples below pass it through `standard`, which words its issues in the full English and was what they were run with. Each example below was run against the version of the library it names, on 2026-10-07: it validated input that passes and input that fails, and gave what the text around it says.

## Forms

A form holds what the user typed, which is the validator's input type and not what it produces, so type the form's values with [`InferInput`](docs/validators.md#inferinput) and what it submits with `Infer`. A field of `coerceNumber()` holds text or a number, and what is submitted is a number.

### react-hook-form

Checked with react-hook-form 7.89.0 and @hookform/resolvers 5.9.1.

```ts
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useForm } from "react-hook-form";

import { email, object, standard, string, type Infer, type InferInput } from "@codenhub/validation";

const signup = standard(object({ name: string({ trim: true, min: 2, max: 100 }), email: email() }));

export function useSignupForm() {
  return useForm<InferInput<typeof signup>, unknown, Infer<typeof signup>>({
    resolver: standardSchemaResolver(signup),
  });
}
```

The resolver gives each field the message of its first issue, in English unless `standard` is given another message map, and the values the validator produced on submit: the name trimmed, and the address as its parser reads it.

### TanStack Form

Checked with @tanstack/form-core 1.33.5. The framework adapters, such as `useForm` of @tanstack/react-form, take the same `validators` option.

```ts
import { FormApi } from "@tanstack/form-core";

import { email, object, standard, string } from "@codenhub/validation";

export const form = new FormApi({
  defaultValues: { name: "", email: "" },
  validators: { onChange: standard(object({ name: string({ min: 2, max: 100 }), email: email() })) },
});
```

A validator given to `validators` reports each issue under its field, `form.state.fieldMeta.email.errors`, as `{ message, path }`.

## RPC and HTTP

### tRPC

Checked with @trpc/server 11.19.0.

```ts
import { initTRPC } from "@trpc/server";

import { email, object, standard, string } from "@codenhub/validation";

const t = initTRPC.create();

export const appRouter = t.router({
  signup: t.procedure.input(standard(object({ name: string({ min: 2, max: 100 }), email: email() }))).mutation(({ input }) => ({ id: "u1", ...input })),
});
```

Input that fails is a `TRPCError` with the code `BAD_REQUEST` and the issues as its cause, and the procedure receives what the validator produced.

### Hono

Checked with hono 4.13.13 and @hono/standard-validator 0.4.0.

```ts
import { sValidator } from "@hono/standard-validator";
import { Hono } from "hono";

import { email, object, standard, string } from "@codenhub/validation";

const signup = standard(object({ name: string({ min: 2, max: 100 }), email: email() }));

const app = new Hono();

app.post(
  "/signup",
  // Without a hook, the 400 response repeats the body it was sent, a password included.
  sValidator("json", signup, (result, c) => {
    if (!result.success) {
      return c.json({ issues: result.error }, 400);
    }
  }),
  (c) => c.json(c.req.valid("json"), 201),
);
```

Give `sValidator` a hook as above. Without one, the response to input that fails repeats the input it was sent, which this package's issues never hold, so a password or a token sent to the endpoint would come back in the response and in whatever logs it.

## Tools of a language model

A model chooses a tool and fills in its arguments from the JSON Schema of its input, so describe each argument with [`meta`](docs/validators.md#meta): the description is what the model reads.

### AI SDK

Checked with ai 7.0.131. The AI SDK writes a tool's input with `~standard.jsonSchema`, which [`standardJsonSchema`](docs/standard-schema.md#with-its-json-schema) adds and `standard` does not, in draft-07.

```ts
import { tool } from "ai";

import { meta, number, object, optional, standardJsonSchema, string } from "@codenhub/validation";

export const forecast = tool({
  description: "Get the weather forecast for a city",
  inputSchema: standardJsonSchema(
    object({
      city: meta(string({ min: 1, max: 100 }), { description: "The city, such as Lisbon" }),
      days: optional(meta(number({ int: true, min: 1, max: 7 }), { description: "How many days ahead" })),
    }),
  ),
  execute: async ({ city, days = 1 }) => `${city}: sunny for ${days} days`,
});
```

Arguments the model sends that fail are refused before `execute` runs, with the issues worded in English.

### Model Context Protocol

Checked with @modelcontextprotocol/sdk 1.32.1. Its `McpServer.registerTool` takes zod schemas only, so the tool is declared on the `Server` below it, which takes a JSON Schema, and the handler refuses a tool it does not have and validates the arguments.

```ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";

import { formatIssue, meta, number, object, optional, string, toJsonSchema } from "@codenhub/validation";

const forecastInput = object({
  city: meta(string({ min: 1, max: 100 }), { description: "The city, such as Lisbon" }),
  days: optional(meta(number({ int: true, min: 1, max: 7 }), { description: "How many days ahead" })),
});

export const server = new Server({ name: "weather", version: "1.0.0" }, { capabilities: { tools: {} } });

server.setRequestHandler(ListToolsRequestSchema, () => ({
  tools: [
    {
      name: "forecast",
      description: "Get the weather forecast for a city",
      inputSchema: { ...toJsonSchema(forecastInput), type: "object" as const },
    },
  ],
}));

server.setRequestHandler(CallToolRequestSchema, (request) => {
  if (request.params.name !== "forecast") {
    throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${request.params.name}`);
  }
  const result = forecastInput(request.params.arguments);
  if (!result.ok) {
    const text = result.error.issues.map((issue) => formatIssue(issue)).join("\n");
    return { isError: true, content: [{ type: "text", text }] };
  }
  const { city, days = 1 } = result.value;
  return { content: [{ type: "text", text: `${city}: sunny for ${days} days` }] };
});
```

## OpenAPI

OpenAPI 3.1 writes schemas in JSON Schema 2020-12, which `toJsonSchema` writes, so a schema goes under `components.schemas` as it is. OpenAPI 3.0 uses an older dialect, which `toJsonSchema` does not write.

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

const signup = meta(object({ name: string({ min: 2, max: 100 }), email: email() }), {
  title: "Signup",
  examples: [{ name: "Ada Lovelace", email: "ada@example.com" }],
});

export const document = {
  openapi: "3.1.0",
  info: { title: "Accounts", version: "1.0.0" },
  paths: {
    "/signup": {
      post: {
        requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Signup" } } } },
        responses: { "201": { description: "Created" } },
      },
    },
  },
  components: { schemas: { Signup: toJsonSchema(signup) } },
};
```

## Inside a zod or valibot schema

A codebase on zod or valibot can use a validator of this package for one field, such as `url()` for a link the server will fetch or `email()` for an address it will store, without moving its other schemas. Neither library takes a Standard Schema as part of its own schemas, so the validator runs in a transform: what it produces is the field's value, and each issue it reports becomes an issue of the other library, worded by `formatIssue`. Only a synchronous validator fits this way.

The issue then follows the other library's rules: valibot's issues hold the input they were given, so what [Issues and messages](docs/errors.md) says about issues never holding a value of the input is true of the validator's result here, not of the issue valibot reports.

### zod

Checked with zod 4.6.5. Each issue keeps its path below the field, so one from an `object` lands on its property.

```ts
import { z } from "zod";

import { email, formatIssue, url, type Validator } from "@codenhub/validation";

const ours =
  <T>(validator: Validator<T>) =>
  (value: unknown, ctx: z.RefinementCtx): T => {
    const result = validator(value);
    if (result.ok) {
      return result.value;
    }
    for (const issue of result.error.issues) {
      ctx.addIssue({ code: "custom", message: formatIssue(issue), path: [...issue.path] });
    }
    return z.NEVER;
  };

const webhook = z.object({
  callback: z.string().transform(ours(url())),
  owner: z.string().transform(ours(email())),
});

webhook.safeParse({ callback: "https://Example.com/hooks/../events", owner: "Ada@EXAMPLE.com" });
// { success: true, data: { callback: "https://example.com/events", owner: "Ada@example.com" } }
webhook.safeParse({ callback: "http://169.254.169.254/latest", owner: "ada@localhost" });
// issues: "Invalid URL" at ["callback"], "Invalid email address" at ["owner"]
```

### valibot

Checked with valibot 1.5.0. The transform takes its input type from the validator, so it follows a schema whose output is what the validator accepts, such as `v.string()` for `url()`. Every issue lands on the field itself.

```ts
import * as v from "valibot";

import { email, formatIssue, url, type Validator } from "@codenhub/validation";

const ours = <T, TInput>(validator: Validator<T, TInput>) =>
  v.rawTransform<TInput, T>(({ dataset, addIssue, NEVER }) => {
    const result = validator(dataset.value);
    if (result.ok) {
      return result.value;
    }
    for (const issue of result.error.issues) {
      addIssue({ message: formatIssue(issue) });
    }
    return NEVER;
  });

const webhook = v.object({
  callback: v.pipe(v.string(), ours(url())),
  owner: v.pipe(v.string(), ours(email())),
});

v.safeParse(webhook, { callback: "https://Example.com/hooks/../events", owner: "Ada@EXAMPLE.com" });
// { success: true, output: { callback: "https://example.com/events", owner: "Ada@example.com" }, ... }
v.safeParse(webhook, { callback: "http://169.254.169.254/latest", owner: "ada@localhost" });
// issues: "Invalid URL" at callback, "Invalid email address" at owner
```

The adapter is a few lines in your code and not an export of this package, so this package's API does not follow the context each library gives a transform, which zod changed between its versions 3 and 4.

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

# JSON Schema

`toJsonSchema(validator)` writes a validator as a [JSON Schema](https://json-schema.org/), draft 2020-12. It is what an OpenAPI document, the tool definitions of a language model and many form generators take, so one validator can both check a value and tell another program what the value must look like.

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

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

toJsonSchema(user);
// {
//   $schema: "https://json-schema.org/draft/2020-12/schema",
//   type: "object",
//   properties: {
//     name: { type: "string", minLength: 2 },
//     email: { type: "string", format: "email" },
//     age: { type: "integer", minimum: 0 },
//   },
//   required: ["name", "email"],
// }
```

The result is a new plain object that `JSON.stringify` can write. It is its own import, so a program that never asks for a schema does not bundle the code that writes one.

## What the schema promises

A schema says less than a validator checks. `email()` refuses an address at `localhost`, and JSON Schema's `email` format has no such rule. So the schema is written to one promise: **a value the validator accepts passes the schema**. The schema may accept a value the validator then refuses, and the validator stays the check that counts. Validate what you receive with the validator, and give the schema to whoever needs to know the shape.

Three places bend that promise, each because the validator cleans a value before it checks it, which a schema cannot say:

- **Clean-up options.** `string({ trim: true, max: 5 })` is written with `maxLength: 5`, which describes text that needs no trimming. Text with spaces around five letters passes the validator and not the schema. `case` is the same, and so is `clamp` beside a limit, as in `number({ max: 10, clamp: { min: 0, max: 10 } })`: the schema describes the value as it is after cleaning. The range of a `clamp` is itself not written.
- **Length of text.** A string's `length` counts an emoji, and any other character outside the Basic Multilingual Plane, as two, and JSON Schema counts it as one, so a `min` or `max` can differ by the number of such characters.
- **Formats that accept several spellings.** `phone()` accepts `+55 (11) 98765-4321` and produces `+5511987654321`. A check on such a format, as in `hostname(pattern(/^[a-z.]+$/))`, sees the spelling the format produces and is written for the text as it arrives, so `EXAMPLE.com` passes the validator and not the schema. A format JSON Schema has no name for is written under its own, such as `"format": "phone"`, which a reader that does not know it ignores.

## Input and output

A validator that changes its value has two shapes: what it accepts and what it produces. `toJsonSchema` writes the input by default, since that is what a request or a tool call must satisfy. Pass `{ io: "output" }` for what the validator produces, such as the body of a response.

```ts
import { coerceNumber, object, oneOf, optional, toJsonSchema } from "@codenhub/validation";

const query = object({
  page: coerceNumber({ int: true }),
  sort: optional(oneOf(["new", "top"]), "new"),
});

toJsonSchema(query).properties;
// { page: { type: ["number", "string"] }, sort: { enum: ["new", "top"], default: "new" } }
// and only `page` is required

toJsonSchema(query, { io: "output" }).properties;
// { page: { type: "integer" }, sort: { enum: ["new", "top"] } }
// and both are required
```

| Validator                                    | Input                                                                                                                                           | Output                                                 |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `optional(validator, value)`                 | The validator's schema, not required, with `default` when the default is a JSON primitive                                                       | The validator's schema, required                       |
| `lazy`, `json` or `searchParams` with checks | For `lazy` and `json`, the checks in an `allOf` beside what it refers to or parses to. For `searchParams`, a string, which says nothing of them | The checks in an `allOf` beside the validator's schema |
| The `coerce` validators                      | The types they convert from                                                                                                                     | The type they produce                                  |
| `transform`                                  | The schema of the validator it converts from                                                                                                    | Cannot be written                                      |
| `pipe`                                       | Its first validator                                                                                                                             | Its last                                               |
| `json(validator)`                            | A string, with `contentSchema` for what it parses to                                                                                            | The validator's schema                                 |
| `searchParams(validator)`                    | A string                                                                                                                                        | The validator's schema                                 |
| `fallback`                                   | Anything, since it never fails                                                                                                                  | The schema of the validator it wraps                   |

The output side takes a default and the value of a `fallback` to pass the validator they are given to. Neither is validated, so `optional(string({ min: 3 }), "")` produces text its own output schema refuses: give a default its validator accepts.

## How each validator is written

| Validator                                                   | Schema                                                                                                                                                                                                                                          |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `string`                                                    | `type: "string"` with `minLength` and `maxLength`                                                                                                                                                                                               |
| `number`                                                    | `type: "number"`, or `"integer"` with `int` or `safeInt`, with `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum`                                                                                                                  |
| `boolean`, `unknown`, `never`                               | `type: "boolean"`, `{}` and `{ not: {} }`                                                                                                                                                                                                       |
| `literal`, `oneOf`                                          | `const` and `enum`                                                                                                                                                                                                                              |
| A format                                                    | `type: "string"` with `format`: `email`, `uuid`, `time`, `duration`, `hostname` as named, `uri` for `url`, `date-time` for `datetime`, `date` for `isoDate`, `hostname` for `domain`, `ipv4` or `ipv6` for `ip`, and its own name for any other |
| `port`, `base64`                                            | An integer from 1 to 65535, and a string with `contentEncoding`                                                                                                                                                                                 |
| `object`, `objectLike`                                      | `type: "object"` with `properties`, `required`, and `additionalProperties: false` for a strict `object`                                                                                                                                         |
| `array`                                                     | `type: "array"` with `items`, `minItems` and `maxItems`                                                                                                                                                                                         |
| `tuple`                                                     | `prefixItems`, with `items` for `rest` and `false` without it                                                                                                                                                                                   |
| `record`                                                    | `propertyNames` and `additionalProperties`, with `minProperties` and `maxProperties`                                                                                                                                                            |
| `union`, `intersection`                                     | `anyOf` and `allOf`                                                                                                                                                                                                                             |
| `tagged`                                                    | `oneOf`, with the tag as a `const` property of each variant                                                                                                                                                                                     |
| `nullable`, `nullish`                                       | `anyOf` with `type: "null"`                                                                                                                                                                                                                     |
| `readonly`, `brand`                                         | What the validator inside is written as                                                                                                                                                                                                         |
| `codec`                                                     | Its `input`, or its `output` with `io: "output"`                                                                                                                                                                                                |
| `lazy`                                                      | A `$ref` to a definition under `$defs`                                                                                                                                                                                                          |
| `pattern`, `startsWith`, `endsWith`, `includes`, `nonBlank` | `pattern`                                                                                                                                                                                                                                       |
| `multipleOf`, `nonZero`, `unique()`                         | `multipleOf`, `not: { const: 0 }` and `uniqueItems`                                                                                                                                                                                             |

A property is left out of `required` when its validator accepts `undefined`: `optional`, `nullish`, `unknown`, `fallback`, a `literal` or a `oneOf` of `undefined`, a `union` with one of those, or a `lazy`, `nullable`, `readonly`, `transform` or `pipe` around one. An `intersection` is left out when every validator in it accepts `undefined`. JSON has no `undefined`, so `literal(undefined)` in a `union` is left out of its `anyOf`, and a `union` or a `oneOf` of nothing else is written as `{ not: {} }`, which no value passes.

A recursive schema is written once, as a definition the schema refers to:

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

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

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

toJsonSchema(category).$defs;
// {
//   schema1: {
//     type: "object",
//     properties: { name: { type: "string" }, children: { type: "array", items: { $ref: "#/$defs/schema1" } } },
//     required: ["name", "children"],
//   },
// }
```

Build a recursive schema once and refer to it, as the [validator reference](docs/validators.md#lazy) says. One built anew at every level, as `lazy(() => build())` inside `build` does, has no end to write, and `toJsonSchema` throws a `TypeError` saying so.

## Saying what a value is for

A schema that gives only the types tells a reader what to send and not what it means. [`meta`](docs/validators.md#meta) adds a title, a description, examples and whether a value is deprecated to the part it is given to, and `toJsonSchema` writes each under JSON Schema's keyword of the same name. A language model reads the `description` of a tool's arguments to decide what to put in them, and an OpenAPI document shows all four.

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

const forecast = meta(
  object({
    city: meta(string({ min: 1 }), { description: "The city to get the weather for", examples: ["Lisbon"] }),
    days: meta(number({ int: true, min: 1, max: 7 }), { description: "How many days ahead" }),
  }),
  { title: "Forecast" },
);

toJsonSchema(forecast);
// {
//   $schema: "https://json-schema.org/draft/2020-12/schema",
//   type: "object",
//   title: "Forecast",
//   properties: {
//     city: { type: "string", minLength: 1, description: "The city to get the weather for", examples: ["Lisbon"] },
//     days: { type: "integer", minimum: 1, maximum: 7, description: "How many days ahead" },
//   },
//   required: ["city", "days"],
// }
```

The keys are written on whatever schema the part is written as, beside a `$ref` for a `lazy` and beside an `anyOf` for a `nullable`, and the same on the input and the output side. In draft-07, which ignores a keyword beside a `$ref`, the reference is wrapped in an `allOf` and the keys are written beside that. Examples are written as given, and are not checked against the validator.

## Drafts

The schema is written in draft 2020-12 unless `target: "draft-07"` asks for the older draft, which some readers, such as the tools of a language model in the AI SDK, still take. In draft-07 the items of a `tuple` are listed under `items` and its `rest` under `additionalItems`, definitions are under `definitions`, and what `json` parses is not written, since that draft has no `contentSchema`. Any other target is a `TypeError`. [`standardJsonSchema`](docs/standard-schema.md#with-its-json-schema) writes the draft a library asks for, through the same function.

## What cannot be written

JSON Schema has no words for some of what a validator can check, and JSON has no value for some of what it can produce:

- `date`, `bigint`, `symbol`, `func`, `instanceOf`, `set` and `map`, none of which is a JSON value, and a validator made by `guard`, whose test is a function nobody can read.
- A validator you wrote by hand, and a check made by `check`, whose rule is a function nobody can read.
- `lowercase`, `uppercase`, `unique` with a key function, and a `pattern` with a flag such as `i`, which JSON Schema patterns do not have. The flags `g` and `y`, which `pattern` drops, are left out.
- What a `transform` returns, on the output side.
- A check on an `object`, and a variant of `tagged` that is not an `object`.

By default `toJsonSchema` throws a `TypeError` that names the part and where it is, such as `toJsonSchema cannot write a validator of kind "date" at createdAt`, so a schema is never quietly weaker than you meant. Pass `{ unrepresentable: "any" }` to write such a part as `{}`, which accepts anything, and to leave such a check out:

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

const post = object({ title: string(check((text) => !text.includes("spam"))), createdAt: date() });

toJsonSchema(post, { unrepresentable: "any" }).properties;
// { title: { type: "string" }, createdAt: {} }
```

To send a date as JSON, validate the text it travels as, with `datetime()` or `isoDate()`, or convert it with `coerceDate()`, whose input side is a string or a whole number of milliseconds.

<!-- 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. Every validator a factory of this package makes is one as it is:

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

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

signup["~standard"].validate({ email: "nope", age: 1.5 });
// {
//   issues: [
//     { message: "Invalid email", 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. Its type is [`Schema`](#the-types), which says so to TypeScript.

## Short English, or `standard`

The specification requires a message on every issue, and a validator's own `~standard` words each in short English: the issue's own `message`, set with the `message` option or by a check, or one sentence for its code, such as "Must be at least 2 characters" or "Invalid email". It is kept short because every program that makes a validator carries it, about 0.4 kB gzipped.

`standard(validator, messages?)` gives the same validator with a `~standard` of its own that words issues as [`formatIssue`](docs/errors.md#turning-an-issue-into-text) does. Use it for:

- **The full English**, such as "Invalid email address" and "Must contain at most 3 items", which is the default.
- **Another language**, with `portugueseMessages` or a map of your own.
- **A validator you wrote by hand**, which has no `~standard` of its own, since this package did not make it.

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

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

## The types

A factory returns a `Schema<T, TInput>`, or an `AsyncSchema<T, TInput>` when one of its rules waits: a `Validator` or `AsyncValidator` that is also a `StandardSchemaV1<TInput, T>`. It is accepted wherever a `Validator` is, so code that takes a validator, such as `assert` or a function of your own, takes it as before. A validator you write by hand is typed `Validator`, which asks for no `~standard`, and `brand` of one stays a `Validator`, since `brand` returns the validator it is given.

## 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 what the validator accepts, [`InferInput`](docs/validators.md#inferinput), and the output type is what it produces, so a library that infers types from a Standard Schema gets both: a form library types the field of a `coerceNumber()` as `string | number`, since a number passes as it is, and what it hands you after validation as a number.

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

## Messages of `standard`

`standard` builds each message from the message map given as its second argument: the built-in English when you give none, `portugueseMessages` for Portuguese, or a map of your own. A program that calls `standard` bundles the English, about 1.7 kB gzipped, even when it passes another map; calling it is what brings the English in, so a program that words its issues with a map of its own and never calls `standard` does not bundle it. A second argument left out or given as `undefined` is the English; any other that is not a map, such as `null`, text or a list, is a `TypeError` when `standard` is called. 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 the map's `default` entry, 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.

## With its JSON Schema

Some libraries need to write the schema down too, such as the AI SDK, which sends the arguments of a tool to a language model as a JSON Schema. They read it from `~standard.jsonSchema`, which [Standard JSON Schema](https://standardschema.dev/json-schema) adds to the specification. `standardJsonSchema(validator, messages?)` returns what `standard` does with it added:

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

const forecast = standardJsonSchema(
  object({
    city: meta(string({ min: 1, max: 100 }), { description: "The city to get the weather for" }),
    days: optional(number({ int: true, min: 1, max: 7 })),
  }),
);

forecast["~standard"].jsonSchema.input({ target: "draft-07" });
// { $schema: "http://json-schema.org/draft-07/schema#", type: "object", properties: { city: { ..., description: "The city to get the weather for" }, ... }, required: ["city"] }
```

`input` and `output` write the validator with [`toJsonSchema`](docs/json-schema.md), the side and the draft as asked. `"draft-2020-12"` and `"draft-07"` are written, and any other target throws a `TypeError`. When a part of the validator cannot be written as JSON Schema, such as a custom check, `toJsonSchema` throws for it, and so do `input` and `output`, unless the library passes `libraryOptions: { unrepresentable: "any" }`. Give [`meta`](docs/validators.md#meta) to the validator before passing it here, since the schema is written from the validator `standardJsonSchema` is given.

It is a separate export so that `standard`, which a form in the browser uses, does not bundle the code that writes a schema: use `standard` where only validation is asked for, and `standardJsonSchema` where the schema is read too.

## 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)["~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, except `__proto__`, which is rejected. |

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" }),
);
```

That check says nothing while any property fails, since there is no whole object to give it. `checkFields(keys, test, issue?)` is a check that names the properties it needs and runs as soon as those have passed, so a form shows "Passwords must match" beside the other fields' problems: `checkFields(["password", "confirm"], (data) => data.password === data.confirm, { path: ["confirm"], message: "Passwords must match" })`. [Custom validators](docs/custom-validators.md#a-rule-across-fields-that-does-not-wait-for-the-others) covers it.

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, which parsed JSON can hold, is reported as `invalid_key` whose `params.issues` holds one `invalid_value` issue with `{ reserved: true }`, whether the input has it or the key validator produces it: kept, it would become the prototype of any copy of the output made by assignment, such as `Object.assign({}, output)`. 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()), () => []);
```

### `brand`

`brand(validator, name)` marks what a validator produces with a name, in the types alone. TypeScript treats two types of the same shape as one, so a function that takes a `string` it calls a user id also takes an email, or text nobody validated. With a brand, the only way to a value of the type is through the validator:

```ts
import { brand, uuid, type Infer } from "@codenhub/validation";

const userId = brand(uuid(), "UserId");
type UserId = Infer<typeof userId>;

declare function loadUser(id: UserId): Promise<unknown>;

const result = userId(input);
if (result.ok) {
  await loadUser(result.value);
}
// loadUser("not validated") is a compile error
```

Nothing changes at run time: `brand` returns the validator it was given, so it validates, is described and is written as a JSON Schema as before, and `pick` and the others still read a branded `object`. The input type is not marked, since what is given to a validator has not passed it yet. `null` and `undefined` are left unmarked, so `brand(optional(string()), "Id")` produces a marked string or `undefined`. Brands add up, so `brand(brand(string(), "A"), "B")` produces a type with both. `Branded<T, "Name">` is the marked type, for when you need to write it.

### `readonly`

`readonly(validator)` makes what a validator produces read-only: in its type, and by freezing the object or array.

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

const settings = readonly(object({ theme: string(), tags: readonly(array(string())) }));
type Settings = Infer<typeof settings>; // { readonly theme: string; readonly tags: readonly string[] }
```

- It freezes the value itself and not what is inside it, as `Object.freeze` does and as the type says. Wrap an inner validator too where its value must not change, as `tags` above.
- A value that is the input itself is never frozen, since the caller owns it: `instanceOf`, `guard` and `unknown` produce what they were given, so `readonly` changes their type alone. `object`, `objectLike`, `array`, `tuple`, `record`, `set` and `map` produce a new value, which is frozen.
- A `Map` or a `Set` is typed `ReadonlyMap` or `ReadonlySet`, and only the type protects it: freezing one does not stop `set` or `add`. A typed array cannot be frozen and is left as it is, and so is a `DataView`. The type covers properties and not methods, so a `Date` keeps its setters.
- A value is taken to be yours when it is the input itself. When a `transform` returns a value from inside the input, such as `(value) => value.tags`, that value is frozen: copy it there when the caller must keep it changeable.
- `pick`, `omit`, `required` and `partial` read an `object`, so reshape first and wrap after: `readonly(pick(user, ["name"]))`.

### `meta`

`meta(validator, { title, description, examples, deprecated })` says what a validator is for, for whoever reads the schema: a person reading an API's documentation, or a language model choosing a tool and filling in its arguments. [`toJsonSchema`](docs/json-schema.md#saying-what-a-value-is-for) writes each key beside the types, under JSON Schema's name for it.

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

const forecast = meta(
  object({
    city: meta(string({ min: 1 }), { description: "The city to get the weather for", examples: ["Lisbon"] }),
    days: meta(number({ int: true, min: 1, max: 7 }), { description: "How many days ahead" }),
  }),
  { title: "Forecast", description: "The weather for the coming days" },
);
```

- It returns a new validator that validates exactly as the one given, whose description is that one's with `meta` added. The validator given is not changed, so the same one can be described differently in two places.
- A `meta` given to a validator that has one replaces the keys it names and keeps the others. A key given as `undefined` is not given.
- `title` and `description` are text, `examples` a list of JSON values, `null`, booleans, finite numbers, text, and lists and plain objects of them, and `deprecated` `true` or `false`; any other value, or any other key, is a `TypeError` when the validator is made. A JSON Schema is JSON, so an example of a `Date` is written as its text. Examples are kept as given and not validated, since they may show the input or the output.
- `pick`, `omit`, `required`, `partial` and `extend` read through it to the object it describes. What they make has no `meta`, since a title written for one object is rarely right for another: give it one of its own.
- What `standard` made stays a Standard Schema under `meta`.

### `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);
```

A `transform` goes one way: [`encode`](#writing-a-value-back) cannot write its value back, and throws naming where it is. Where a value must go back, as a `Date` to the text a request carries, use a `codec`.

### `codec`

`codec(input, output, { decode, encode })` reads a value as it is sent into the value a program uses, and can write it back. It validates with `input`, gives what that produced to `decode`, and validates the result with `output`, so both ends are checked. [`encode`](#writing-a-value-back) runs the other way, through `encode`.

```ts
import { bigint, codec, date, datetime, pattern, string } from "@codenhub/validation";

const timestamp = codec(datetime(), date(), {
  decode: (text) => new Date(text),
  encode: (value) => value.toISOString(),
});
// The input accepts only the text BigInt reads, so decode never throws.
const amount = codec(string({ max: 40 }, pattern(/^-?\d+$/)), bigint(), {
  decode: (text) => BigInt(text),
  encode: (value) => value.toString(),
});
```

- `decode` is given what `input` produced and returns what `output` validates; `encode` is given what `output` produced and returns what `input` validates. Each is typed so.
- A function that throws is a bug and propagates, as a `transform`'s does. To fail, return a value the other validator refuses: a `decode` that gives an invalid `Date` fails `date()`. Or let `input` accept only what `decode` can read, as `amount` does, since `BigInt` throws for other text.
- Both are synchronous: one that returns a promise throws a `TypeError` when it is called, since the codec is typed as not waiting. A rule that waits goes in a check.
- It is described as `{ kind: "codec", input, output, decode, encode }`, and written as a JSON Schema as its `input`, or its `output` with `io: "output"`. An output JSON Schema cannot say, such as the `Date` of `timestamp`, throws there unless given `unrepresentable: "any"`, as [JSON Schema](docs/json-schema.md) says.

## 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, and `checkFields(keys, test, issue?)` one for an object that waits only for the properties it names; [Custom validators](docs/custom-validators.md) covers both 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() });
```

### `pick`, `omit`, `required`, `partial` and `extend` on a validator

When what you have is the validator and not its shape, such as one another module exports, these are functions of the validator. Each takes a validator `object` made and returns a new one, and leaves the one it was given as it is:

```ts
import { boolean, email, extend, number, object, omit, optional, partial, pick, required, string } from "@codenhub/validation";

const user = object({ id: number(), name: string({ min: 2 }), email: email(), bio: optional(string()) });

const contact = pick(user, ["name", "email"]); // { name: string; email: string }
const draft = omit(user, ["id"]); // everything but the id
const patch = partial(user); // every property optional
const complete = required(user); // bio is required too
const admin = extend(user, { admin: boolean() }); // every property of user, and admin
```

- `pick(validator, keys)` keeps the named properties and `omit(validator, keys)` leaves them out. A key the object does not have is a `TypeError` when the validator is created, since it is a misspelling.
- `partial(validator)` wraps every property in `optional`, as `partial(shape)` does.
- `extend(validator, shape)` adds the properties of `shape`, as spreading one shape into another does: a property the object has is replaced by the one given, in its place.
- `required(validator)` makes every property required: one made with `optional` becomes the validator it wrapped, so its default is removed too and a missing property fails, and one made with `nullish` becomes `nullable`, which still accepts `null`. A property that accepts `undefined` some other way, such as `unknown()`, is kept as it is.

The new object keeps the options of the one it came from, `unknownKeys` and `message`, and takes options and checks of its own after its arguments, as `object` does: `pick(user, ["name"], { unknownKeys: "strict" }, check(...))`.

An object with checks is refused with a `TypeError`. A check reads the whole object, so it may read a property the new object lacks: kept, it could fail every value, and dropped without a word, the new object would accept what the first was written to refuse. Make the object without its checks, derive from that, and give each result the checks it needs.

They need a validator `object` made, which is how they know its shape: `objectLike`, a `pipe` or `transform` around an object, and a validator written by hand are a `TypeError`. [Reading a schema](#reading-a-schema) is how they read it, and how you would write one of your own.

## Forms with files

A form that sends a file arrives as a `FormData`, from `request.formData()` on a server or `new FormData(form)` in the browser. `formData` reads it and `file` checks each file in it:

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

const profile = formData(
  object({
    name: string({ trim: true, min: 2, max: 100 }),
    avatar: optional(file({ maxSize: 1_000_000 }, contentType(["image/png", "image/jpeg"]))),
  }),
);

export const readProfile = async (request: Request) => profile(await request.formData());
```

### `formData`

`formData(validator, options?, ...checks)` reads a `FormData`, from this realm or another such as an iframe, into an object of its fields, each a string or a `File`, and produces what `validator` makes of it. A key given more than once fails at its path with `invalid_key`, as in [`searchParams`](#searchparams), unless `repeated` is set, which gives every key a list, as a field of several files or several checked boxes of one name needs: `formData(object({ photos: array(file({ maxSize: 5_000_000 }), { max: 10 }) }), { repeated: true })`. Anything else, a `URLSearchParams` or a plain object included, fails with `invalid_type` and `{ expected: "form data", received }`.

The form has been read before it reaches the validator, and reading it reads the whole body, so cap the size of the body where the request is read, and give each `file` a `maxSize`. `audit` reads through `formData` to what it holds, and does not report it. `toJsonSchema` and `encode` refuse it, as they do any validator they cannot write.

### `file`

`file(options?, ...checks)` accepts a `File`, from this realm or another, and produces it. A `Blob` is no file, since a form sends a file with its name, and neither is an object that only claims to be one. Its content is never read by the options, and no issue holds its name, its type or a byte of it.

| Option    | Fails with                                                     |
| --------- | -------------------------------------------------------------- |
| `minSize` | `too_small`, `{ minimum, type: "file" }`, in bytes             |
| `maxSize` | `too_big`, `{ maximum, type: "file" }`, in bytes               |
| `types`   | `invalid_value`, `{ options, type: "file" }`, the types listed |

`types` compares the media type the file declares without letter case and without parameters such as `;charset=utf-8`. The client declares whatever it likes, so `types` alone lets through a script named `photo.png` with the type `image/png`. `audit` reports a `file` without a `maxSize`, since the program holds whatever size it accepts.

### `contentType`

`contentType(types, message?)` is a check for `file` that reads the first bytes of the file and passes when they are those of one of the types given, whatever the file declares. It knows `image/png`, `image/jpeg`, `image/gif`, `image/webp` and `application/pdf`, and throws for any other. The first bytes are the signature of a format, not proof that the rest is well formed, so a program that decodes the file still handles one it cannot decode. Reading is asynchronous, so a `file` given it is an `AsyncValidator`, and it runs once every option of the `file` passed, so `maxSize` refuses a large file before a byte of it is read. Content that does not match fails with `invalid_value` and `{ options, content: true }`, and a file that cannot be read with `{ unreadable: true }`.

## 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.

## Writing a value back

`encode(validator, value)` writes a value of the type a validator produces back to what the validator accepts: the `Date` a `codec` read back to its text, an object of them back to an object of text, and what `json` parsed back to JSON. It returns a result, as a validator does.

```ts
import { codec, date, datetime, encode, object, string } from "@codenhub/validation";

const timestamp = codec(datetime(), date(), {
  decode: (text) => new Date(text),
  encode: (value) => value.toISOString(),
});
const event = object({ name: string(), at: timestamp });

encode(event, { name: "Launch", at: new Date(0) });
// { ok: true, value: { name: "Launch", at: "1970-01-01T00:00:00.000Z" } }
```

- **A `codec`** validates the value with its output, runs its `encode`, and checks its input accepts the result.
- **A part that produces what it accepts**, such as `string`, `number`, a format or a coercion, validates the value and gives what it produced: `email()` gives the address as the parser reads it, and `coerceNumber()` gives the number, which it accepts.
- **A composer**, such as `object`, `array`, `tuple`, `record`, `map`, `set`, `union` or `tagged`, writes back each of its parts with its own options, and runs its checks on the value once its parts passed. `optional`, `nullable` and `nullish` keep what they let through, and a `pipe` writes its steps back from the last.
- **`json` and `searchParams`** write text: `JSON.stringify` of what their validator wrote back, and a query string with each value as text and each item of a list as a repeated key. The text is then read back by the validator it came from, so text it would refuse fails: a list written as a repeated key, which `searchParams` reads only with `repeated: true`, and `undefined`, which is no JSON. JSON holds less than JavaScript, so what `JSON.stringify` changes is changed: `NaN` is written as `null`, and a property that is `undefined` is left out.
- **`optional` with a default** writes the default back as `undefined` when its inner part refuses it, since only `undefined` produced it: `optional(string({ min: 3 }), "")` writes `""` back as `undefined`. A default made by a function cannot be compared, and a `fallback` value its inner part refuses fails, since every input its inner part refuses produces it, and which one was given is not known.
- **What cannot go back throws** a `TypeError` naming its place: a `transform`, whose function goes one way, and a validator written by hand. Use a `codec` there.

So a value the validator could not have produced fails with the issues it has, at their paths: `encode(timestamp, new Date(Number.NaN))` fails as `date()` would. It is synchronous unless a part waits. A coercion writes back the value it produced, which it accepts, so inside `json` a `bigint` from `coerceBigint()` cannot be written as JSON: use a codec that writes it as text, as `amount` above does.

`encode` builds, on each call, a validator of each part of the schema the other way, from the same factories, so it bundles every composer: about 8 kB gzipped with an object and a codec.

## Exposing a validator to other libraries

Every validator a factory makes is a [Standard Schema](docs/standard-schema.md) as it is, so libraries that accept one take it directly, and its issues are worded in short English. `standard(validator, messages?)` returns the validator with a `~standard` that words them with its `messages` map instead, the full English when left out, and makes a validator you wrote by hand a Standard Schema. `standardJsonSchema(validator, messages?)` also carries the validator's JSON Schema, for a library that writes the schema down, such as the AI SDK for the tools of a language model: [With its JSON Schema](docs/standard-schema.md#with-its-json-schema).

## 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.

## Reading a schema

`describe(validator)` returns what a validator is made of, as plain data: its `kind`, the `options` it was given, its `checks`, and the validators inside it. A program walks a schema with it, to write the schema in another notation, to build a form from it, or to make another validator from its parts.

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

const signup = object({ name: string({ min: 2 }), email: email(), nickname: optional(string()) });

const top = describe(signup);
top?.kind; // "object"
const shape = top?.["shape"] as Record<string, AnyValidator>;
describe(shape["name"] as AnyValidator)?.options; // { min: 2 }
describe(shape["email"] as AnyValidator)?.["format"]; // "email"
describe(shape["nickname"] as AnyValidator)?.kind; // "optional"
```

A validator inside another is given as the validator itself, to be described in turn, so a recursive schema is read one level at a time and never without end. The description and its options are frozen, and are a copy of what the factory was given, so changing an options object afterwards changes neither the validator nor what it says of itself.

| `kind`                                                                                        | Made by                                           | Beside `options` and `checks`                                                                                                   |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `"string"`, `"number"`, `"bigint"`, `"boolean"`, `"date"`, `"symbol"`, `"unknown"`, `"never"` | The validator of that name                        |                                                                                                                                 |
| `"function"`                                                                                  | `func`                                            |                                                                                                                                 |
| `"literal"`                                                                                   | `literal`                                         | `value`                                                                                                                         |
| `"oneOf"`                                                                                     | `oneOf`                                           | `values`, a list, for an enum too                                                                                               |
| `"instance"`                                                                                  | `instanceOf`                                      | `target`, the class                                                                                                             |
| `"guard"`                                                                                     | A factory `guard` made                            | `expected`                                                                                                                      |
| `"format"`                                                                                    | Every format, `port`, and a factory `format` made | `format`, its name, as in the issue it reports                                                                                  |
| `"coerce"`                                                                                    | The `coerce` validators                           | `inner`, the strict validator, which has the options; `options` for an option of the coercion's own, `zoneless` of `coerceDate` |
| `"object"`, `"objectLike"`                                                                    | `object`, `objectLike`                            | `shape`                                                                                                                         |
| `"array"`, `"set"`                                                                            | `array`, `set`                                    | `item`                                                                                                                          |
| `"tuple"`                                                                                     | `tuple`                                           | `items`, and `rest` when it has one                                                                                             |
| `"record"`, `"map"`                                                                           | `record`, `map`                                   | `key`, `value`                                                                                                                  |
| `"union"`, `"intersection"`                                                                   | `union`, `intersection`                           | `members`                                                                                                                       |
| `"tagged"`                                                                                    | `tagged`                                          | `key`, `variants`                                                                                                               |
| `"optional"`                                                                                  | `optional`                                        | `inner`, `default`; no `options` or `checks`                                                                                    |
| `"nullable"`, `"nullish"`                                                                     | `nullable`, `nullish`                             | `inner`; no `options` or `checks`                                                                                               |
| `"readonly"`                                                                                  | `readonly`                                        | `inner`; no `options` or `checks`                                                                                               |
| `"fallback"`                                                                                  | `fallback`                                        | `inner`, `value`; no `options` or `checks`                                                                                      |
| `"transform"`                                                                                 | `transform`                                       | `inner`, `convert`; no `options` or `checks`                                                                                    |
| `"pipe"`                                                                                      | `pipe`                                            | `steps`; no `options` or `checks`                                                                                               |
| `"codec"`                                                                                     | `codec`                                           | `input`, `output`, `decode`, `encode`; no `options` or `checks`                                                                 |
| `"lazy"`                                                                                      | `lazy`                                            | `getter`, which returns the validator                                                                                           |
| `"json"`, `"searchParams"`                                                                    | `json`, `searchParams`                            | `inner`                                                                                                                         |
| `"check"`                                                                                     | Every built-in check                              | `code` and `params` of the issue it reports                                                                                     |

A check is described the same way, so the list in `checks` can be read too: `describe(pattern(/^a/))` gives `{ kind: "check", code: "invalid_format", params: { format: "regex", pattern: "/^a/" } }`. A check made by `checkFields` gives `{ kind: "check", fields }`, the properties it waits for, and no `params`, since its test is a function. What nobody can read gives `undefined`: a validator or a check you wrote by hand, and a check made by `check`, whose test is a function. A reader of a schema decides what such a rule means to it, and code that turns a schema into another notation usually refuses it. The function a `transform` converts with and a `lazy`'s getter are in the description as functions, for the same reason. The test of a `guard` and what a format reads with are not in it: a `guard` gives the `expected` it names and a format its name.

What `standard` returns is described as the validator it wraps, and so is what `meta` returns, with a `meta` part added: `{ title, description, examples, deprecated }`, each key only when given. A validator written by hand that `meta` was given is described as `{ kind: "meta", inner, meta }`. The parts are typed as `unknown`, since what a part is depends on the kind: read `kind` first, then the parts of that kind from the table.

To write a whole schema as JSON Schema, for an HTTP API or the tools of a language model, see [JSON Schema](docs/json-schema.md).

## Checking a schema for the bounds it needs

Input that passes a validator is validated in full, so a body of a million items, or of text a gigabyte long, costs what its size costs, and is what you store. `audit(validator)` reads a schema, without calling it, and lists every place where the size of what it accepts is left open, so a test can hold a schema for input nobody controls to the bounds it needs:

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

const signup = object({ name: string({ max: 100 }), email: email(), tags: array(string({ max: 20 })) });

audit(signup); // [{ rule: "unbounded_size", path: "tags", kind: "array" }]
```

Give `tags` a `max`, as `const bounded = object({ name: string({ max: 100 }), email: email(), tags: array(string({ max: 20 }), { max: 10 }) })`, and the list is empty, which a test asserts: `expect(audit(bounded)).toEqual([])`. Each finding has a `rule`, the `path` of the part in the schema and its `kind`:

| `rule`             | Reported for                                                                                                                                      |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"unbounded_size"` | An `array`, `set`, `map` or `record` without a `max` or `length`, a `tuple` with `rest` and no `max`, and a `file` without a `maxSize`            |
| `"unbounded_text"` | A `string` or `coerceString` without a `max` or `length`, a format that does not bound its own text, and `json` or `searchParams` given open text |
| `"raised_limit"`   | A `lazy` whose `maxDepth` or `maxCalls` is above its default                                                                                      |
| `"unreadable"`     | A validator it cannot read, such as one written by hand, whose bound it cannot tell                                                               |

- **Formats whose input is bounded by what they accept** need no `max`: `email`, `uuid`, `ulid`, `cuid2`, `nanoid`, `ip`, `cidr`, `mac`, `port`, `phone`, `creditCard`, `isoDate`, `hostname` and `domain`. Any other, such as `url`, `hex`, `jwt` or one made with `format`, is text like any other: bound it with a `pipe`, as below.
- **A bound before it counts.** A `pipe` step reads what the steps before it produced, so after a step that bounds its value nothing needs a bound of its own: in `pipe(string({ max: 10_000 }), json(object({ tags: array(string()) })))` the text is at most 10,000 characters, and so is everything parsed from it. `pipe(string({ max: 2048 }), url())` bounds a URL the same way.
- **A path** joins property names with `.`, and writes `[]` for the items of an array or a set, `[0]` for an item of a tuple, `{}` for the keys and values of a record or a map, and `""` for the validator itself. A recursive schema is read once at each place it is used, and not again where it refers to itself.
- **What it does not judge** is whether a `max` is small enough: any number says someone decided. Checks are not read, since they run on a value that already passed, and neither is anything a `guard` or a validator written by hand does inside.

## Working with results

### `formatIssue`, `flatten`, `formatPath` and the message maps

`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 the entry for its code in the message map, `englishMessages` unless you pass another, then the map's `default` entry, then "Invalid value". `englishMessages` is a value of its own, so a program that never calls `formatIssue`, `flatten` or `standard` 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. `portugueseMessages` is the same map in Portuguese, as written in Brazil; [Issues and messages](docs/errors.md#built-in-languages) covers both.

### `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.

### `InferInput`

`InferInput<typeof validator>` is the type of input that can pass, which is the type a form holds before it is validated. The two differ wherever a validator changes its value:

```ts
import { coerceNumber, object, oneOf, optional, type Infer, type InferInput } from "@codenhub/validation";

const settings = object({ page: coerceNumber(), theme: optional(oneOf(["light", "dark"]), "light") });

type Settings = Infer<typeof settings>; // { page: number; theme: "light" | "dark" }
type SettingsInput = InferInput<typeof settings>; // { page: string | number; theme?: "light" | "dark" | undefined }
```

It differs from `Infer` for `optional` with a default, the `coerce` validators, `transform`, `pipe`, which accepts what its first validator does, `json`, which accepts text, `searchParams`, which accepts text or a `URLSearchParams`, typed as `string | Iterable<[string, string]>` so that the type names no global of a runtime, and `fallback`, which accepts anything. Clean-up is not a difference: `string({ trim: true })` accepts and produces a `string`.

A validator is still called with any value at all. The input type says what can pass, for the types of a form or of a caller, and is never a limit on the argument. A validator you write by hand has `unknown` unless its type says otherwise, as `Validator<number, string>` does for one that reads a number from text. `standard` gives the input type to the library that takes the schema, so a form library types its fields from it.

### `is`

`is(validator, input)` returns whether `input` passes, as a `boolean`. It does not narrow `input`: a type guard also says that a value that fails is not of the type, and `string({ min: 3 })` refuses `"ab"`, which is a string. Read `result.value` from calling the validator for the typed value, or wrap `is` in a guard of your own for the one type you mean, `(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`, `InferInput`, `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.4.0.md -->

# 0.4.0

0.4.0 adds a schema another program can read, a type for what a validator accepts, and a rule across fields that a form sees at once. It also makes objects and collections about three times faster on valid input. Every call that 0.3.0 accepted is accepted, and returns what it returned, with two exceptions: a message map that has an entry named `default` now words unknown codes with it, and `record` and a passthrough `object` reject a `__proto__` key, which is a breaking change. The other breaking change is in the types.

## Added

- `describe(validator)`, which returns what a validator is made of as plain, frozen data: its `kind`, the `options` it was given, its `checks`, and the validators inside it. Every factory and every built-in check of the package records it; a validator or a check written by hand gives `undefined`. [Reading a schema](docs/validators.md#reading-a-schema) lists what each kind holds.
- `toJsonSchema(validator, { io, unrepresentable })`, which writes a validator as a JSON Schema, draft 2020-12, for an OpenAPI document, the tools of a language model or a form generator. It writes what the validator accepts, or what it produces with `io: "output"`. A part JSON Schema cannot say, such as a custom check, a `date` or what a `transform` returns, which only `io: "output"` asks for, throws a `TypeError` naming where it is, or is written as accepting anything with `unrepresentable: "any"`. [JSON Schema](docs/json-schema.md) covers it.
- `pick(validator, keys)`, `omit(validator, keys)` and `required(validator)`, which make a new object validator from one `object` made, and `partial(validator)`, which `partial` accepts beside a shape. The new validator keeps the options of the one it was made from. One that carries checks is refused with a `TypeError`, since a check was written for the whole object.
- `meta(validator, { title, description, examples, deprecated })`, which says what a validator is for. `toJsonSchema` writes each key on the part it is given to, so the tools of a language model and an OpenAPI document carry a description of every argument. It returns a validator that behaves as the one given, and `describe` gives that one's description with `meta` added. Each example is a JSON value, since a JSON Schema is JSON.
- `extend(validator, shape, ...rest)`, which makes a new object validator with the properties of one `object` made and those given, a property of the same name replacing the old one, as spreading two shapes does. What follows the shape is what follows one in `object`: options, which replace those of the object, and then checks on the new object. Like `pick`, it keeps the options of the object when given none and refuses one with checks.
- `audit(validator)`, which reads a schema and lists every place where the size of what it accepts is left open: a collection or text with neither a `max` nor a `length`, a `lazy` whose limits were raised above their defaults, and a part it cannot read. A test asserts the list is empty, so a schema for input nobody controls keeps the bounds it needs.
- `codec(input, output, { decode, encode })`, a validator that reads a value as it is sent, such as text, into the value a program uses, such as a `Date`, checking both ends, and `encode(validator, value)`, which writes a value back to what a validator accepts through every codec, composer, `json` and `searchParams` in it. A `transform` or a validator written by hand cannot be written back, and `encode` throws naming where it is.
- `standardJsonSchema(validator, messages?)`, which returns what `standard` returns with `~standard.jsonSchema` added, from the Standard JSON Schema part of the specification, so a library that writes the schema down, such as the AI SDK for the tools of a language model, takes the validator as it is. It is an export of its own so that `standard` does not bundle the code that writes a schema.
- `formData(validator, { repeated })`, which reads a `FormData` into an object of its fields, each text or a `File`, and validates it, refusing a key given twice unless `repeated` is set, as `searchParams` does for a query; `file({ minSize, maxSize, types })`, which accepts a `File` and bounds its size and the media type it declares; and `contentType(types)`, a check for `file` that reads the file's first bytes and passes PNG, JPEG, GIF, WebP or PDF content whatever type the file declares. A `file` is typed as the runtime's `File`. `audit` reports a `file` without a `maxSize`. [Forms with files](docs/validators.md#forms-with-files) shows them together.
- The `target` option of `toJsonSchema`, `"draft-2020-12"` by default or `"draft-07"`, for a reader that does not know 2020-12.
- `InferInput<typeof validator>`, the type of input that can pass, beside `Infer`, the type produced. They differ for `optional` with a default, the `coerce` validators, `transform`, `pipe`, `json`, `searchParams` and `fallback`. `Validator<T, TInput>` and `AsyncValidator<T, TInput>` take it as a second type, `unknown` when left out, so a validator written by hand is unchanged. A validator is still called with any value: the type says what can pass and limits no argument.
- `checkFields(keys, test, issue?)`, a check for `object` and `objectLike` that names the properties it needs and runs as soon as those have passed, whatever the others did. A form with an empty name now shows "Passwords must match" beside the name's own issue, where a `check` waits until every property is valid. Its test is given the named properties alone, typed so, and a name the object lacks is a `TypeError` when the object is created.
- `brand(validator, name)`, which marks the type a validator produces, leaving `null` and `undefined` unmarked, so only a value that went through the validator is accepted where that type is asked for. It returns the validator it was given and changes nothing at run time. `Branded<T, "Name">` is the marked type.
- `readonly(validator)`, which makes what a validator produces read-only: in its type, a `Map` and a `Set` as `ReadonlyMap` and `ReadonlySet`, and by freezing the object or array the validator produced. It freezes one level, and never a value that is the input itself, such as what `instanceOf` or `unknown` produces.
- `portugueseMessages`, the built-in wording in Portuguese as written in Brazil, used wherever `englishMessages` is: "Deve ser no mínimo 18", "Endereço de e-mail inválido". It is a separate value, so a program bundles only the languages it imports.
- The entry `default` of a message map, which words an issue whose code has no entry of its own, such as the code of a custom check, where `formatIssue` said "Invalid value" in English whatever the language of the map. `portugueseMessages` has one, "Valor inválido"; a map of your own may give text or a function of the issue.
- The types a consumer may need to name for the above: `StandardJSONSchemaV1`, `Conversions`, `Encoded`, `AuditFinding`, `Meta`, `Extended`, `Description`, `JsonSchema`, `JsonSchemaOptions`, `Picked`, `Omitted`, `AllRequired`, `AllOptional`, `Reshaped`, `InferShapeInput`, `InferTupleInput`, `InferTaggedInput`, `InferRecordInput`, `Branded` and `ReadonlyOutput`.
- Every validator a factory makes is a [Standard Schema](docs/standard-schema.md) as it is, so a library that validates with a Standard Schema, such as a form library or a router, takes it without a `standard` call. One that also reads its JSON Schema, such as the AI SDK for the tools of a language model, still takes `standardJsonSchema(validator)`. Its issues are worded in short English, such as "Must be at least 2 characters"; `standard(validator, messages?)` still gives the full English, another language, or a Standard Schema from a validator written by hand. Factories return the new types `Schema` and `AsyncSchema`, which extend `Validator` and `AsyncValidator` and are accepted wherever those are. It costs every program about 0.4 kB gzipped: a lone `boolean()` grew from 1.34 kB to 1.84 kB.
- [Integrations](docs/integrations.md), a page with an example for each of react-hook-form, TanStack Form, tRPC, Hono, the AI SDK, the Model Context Protocol and OpenAPI, each run against the version it names, and a transform that runs a validator, such as `url()`, as one field of a zod or valibot schema.

## Changed

- `email()`, `domain()` and the `mailto` addresses of `url()` read an ASCII domain such as `example.com` without the URL parser, whose reading of one is the text in lowercase, so `email()` validates about twice as many addresses a second. What each accepts and returns is unchanged, and a test compares the two readings over generated hosts.
- `formatIssue(issue)` and `flatten(failure)` word issues in English when given no message map, where 0.3.0 threw a `TypeError`, so the first call of a form needs no import of `englishMessages`. `undefined` there gives the English too, and any other value that is not a map, such as `null` or a list, is still a `TypeError`. A program that calls either now bundles the English, about 1.7 kB gzipped, even when it passes a map of its own, since it is their default. `assert` is unchanged: without a map it still says "Invalid value", and bundles no English.
- `standard(validator)` words issues in English when given no message map, where 0.3.0 threw a `TypeError`. `undefined` there gives the English too. A map, such as `portugueseMessages`, is still accepted as the second argument, and any other value there, such as `null`, text or a list, is still a `TypeError`. A program that calls `standard` now bundles the English even when it passes a map of its own.
- **Breaking.** `standard(validator, messages)` reports the validator's input type to the library that takes it, where 0.3.0 reported `unknown`. A form library that types a field from a Standard Schema now types one for `coerceNumber()` as `string | number` and one for `optional(string(), "")` as `string | undefined`, so code that relied on the field being `unknown` may need the narrower type.
- **Breaking.** `record` and an `object` with `unknownKeys: "passthrough"` reject a `__proto__` key with an `invalid_key` issue, whose `params.issues` holds one `invalid_value` issue with `{ reserved: true }`, where 0.3.0 kept the key as data of the output. Kept, it became the prototype of any copy of the output made by assignment: `Object.assign({}, output)` of the validated `{"__proto__":{"admin":true}}` had `admin`. An `object` that strips unknown keys still drops it, a strict one still reports `unrecognized_key`, and a shape that lists `__proto__` still keeps it as data.
- `Validator` and `AsyncValidator` are interfaces with a call signature, where they were function types, so that they can carry the input type. A function of the right shape is still a validator, and nothing a caller writes changes.
- Every validator a factory of the package makes, and every built-in check, has a property named `~description`, which `describe` reads; a check made by `check` has none, since its rule is a function. The record it holds is frozen, and holds no input.
- A message map with an entry named `default` words every code it has no entry for with it, as above. A map that used `default` as the code of one of its own issues now words other unknown codes with that text too.
- The package description, the README and the documentation overview lead with what the package is for, validating input nobody you trust controls, and state its size as a measurement.

## Performance

Measured on Node.js 24.19 on one machine, valid input, in millions of validations a second, the published 0.3.0 against this release with every result kept. `pnpm bench` in the package measures this release; the 0.3.0 column is the same script given the published build, which is not in the repository.

| Validator                         | 0.3.0 | 0.4.0 |
| --------------------------------- | ----- | ----- |
| `string()`                        | 39    | 72    |
| `object` of three strings         | 1.25  | 4.2   |
| `array` of 100 strings            | 0.20  | 0.65  |
| `email()`                         | 1.1   | 1.5   |
| `{ name, email, age }`, as a form | 0.53  | 0.93  |

- `object`, `objectLike`, `array`, `set`, `tuple` and `record` no longer make a path, a list or a closure for a child that passes without waiting. What they guarantee is unchanged: the input is read before any child runs, results keep the order of the children, and a pending result of a sibling is handled when a child throws.
- Valid input is answered without the full work where a validator can: a built-in leaf or format, and `optional`, `nullable` and `nullish`, each without checks, and an `object`, `array`, `tuple`, `record`, `union` or `tagged` all of whose parts can, which no longer makes a result, a path or a list for each part that passes. A part whose answer would run code you wrote has no quick answer and is always validated in full, so that code runs once: a check, which also takes the quick answer from a leaf it is added to, the test of a `guard` or of a `format` you made, `instanceOf` a class with a custom `Symbol.hasInstance`, and `optional` with a default made by a function. Input that fails is validated as before and reports the same issues, for input that gives the same value each time it is read, as data parsed from JSON does. Measured before and after this change in a whole run of `pnpm bench`, an `object` of three strings went from 3.8 to 8.7 million validations a second and an `array` of 100 strings from 0.55 to 2.0, and, in a later change, a `tuple` of two numbers about three times faster, a `record` of ten numbers about two and a half, a `union` that accepts its second option about five, and `tagged` about two; the table above was measured before it, and is measured again for the release. A getter, or a `Proxy` trap that reads a value, is read once for input that passes, while a trap that lists or tests keys, as a strict object asks both, may run more than once; for input that fails, what the quick answer read before it stopped is read again, and a format the quick answer reached parses the text a second time, so one that gives another value the second time, or changes the input as it is read, can change the result.
- `email()` and `url()` parse text that passes once, with `URL.parse` where the runtime has it, where they asked whether it parses and then parsed it.

## Size

A program that reads no schema pays for the description every validator now carries: a lone `boolean()` is 1.24 kB gzipped, from 1.19 kB, and an object with a string, an email and an optional integer 5.79 kB, from 5.37 kB. `toJsonSchema` with an object of one string is 5.1 kB, and `portugueseMessages` with `formatIssue` 2.5 kB.

## Migrating from 0.3.0

- Where a form library's field type changed, type the field as `InferInput<typeof validator>`, which is what the library now reads.
- A check that compares fields of a form, such as a password and its confirmation, is better written with `checkFields`. `check` still works and still waits for every property.
- A dictionary that must hold a `__proto__` key is read with `map`, from a `Map`, or lists the key in the shape of an `object`.
- A translation that worded custom codes one by one to avoid "Invalid value" can give one `default` entry.

<!-- 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
}
```
