<!-- 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 the reasons 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 URL parser of the platform sees, so the value you store is the one a request or a mail server will use.
- **What a failing input reports is bounded.** Each collection stops reporting at 1,000 issues, as [Limits](docs/limits.md#a-failing-input-has-a-bounded-cost) explains, and input nested too deep is one issue, not a stack overflow, as long as the depth limit of a recursive schema, [`maxDepth`](docs/limits.md#maxdepth), fits the stack.
- **Issues never hold a value of the input**, so logging a failed validation cannot log a password that was sent. They do hold what the schema was configured with, such as the values a `oneOf` accepts or the prefix of a `startsWith`, and the input's 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 bundles only the validators it uses. No dependencies, so the only code to audit is ours.

> [!WARNING]
>
> **Experimental:** pre-1.0. The API is meant to hold for every patch release of a minor version, such as each 0.4.x. A change that breaks callers, if one proves necessary, ships in the next minor version, and the [changelog](docs/changelog/index.md) lists it, with the migrations from earlier versions.

## Installation

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

## Usage

A validator is a function. That is most of the design. Build one for the shape you expect, call it with any value, and read the result:

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

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

type Signup = Infer<typeof signup>; // { name: string; email: string }

const result = signup(requestBody);

if (result.ok) {
  result.value.name; // a string, trimmed
} else {
  flatten(result.error).fieldErrors; // for example { email: ["Invalid email address"] }
}
```

Invalid input does not throw: the result says why it failed. `signup` is also a [Standard Schema](docs/standard-schema.md), so a form library or a router that takes one takes it directly.

## Documentation

- [Documentation overview](docs/index.md), with the reading path from a first validator to the limits of untrusted input
- [Getting started](docs/getting-started.md)
- [Forms](docs/forms.md) and [Request bodies](docs/request-bodies.md)
- [Issues and messages](docs/errors.md)
- [API reference](docs/reference/index.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/getting-started.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 one canonical spelling. See [Formats](docs/formats.md).
- **`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/formats.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/getting-started.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. [Limits](docs/limits.md) has the details, and [what hostile input gets](docs/limits.md#what-hostile-input-gets).

## License

Licensed under [Apache-2.0](LICENSE).

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

# Validate values and shapes

`@codenhub/validation` answers one question: is this value what I said it should be? You give a value to a validator. You get back either that value, typed, or a 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.

## Why validate

Data that crosses a boundary you do not control can hold anything: a request, a form, a query string, an environment variable, a file, a message from another service. TypeScript checks types when you compile, not when the program runs. So the type you wrote for that data is a promise, and the data never agreed to it. A validator is what makes it keep it.

The package is built for that boundary, where the input may be hostile:

- **A format gives you what the parser read.** `email()` and `url()` produce the value the platform's URL parser reads. The value you store is the one a request or a mail server will use.
- **A failing input has a bounded report.** Each collection stops reporting at 1,000 issues, as [Limits](docs/limits.md#a-failing-input-has-a-bounded-cost) explains. Input nested too deep is one issue, not a stack overflow, as long as the depth limit of a recursive schema, [`maxDepth`](docs/limits.md#maxdepth), fits the stack.
- **An issue never holds a value of the input.** A log of failures holds no password that was sent. An issue does hold what the schema was configured with, such as the values a `oneOf` accepts or the prefix of a `startsWith`, and the input's keys, in its `path`, as [The issue](docs/errors.md#the-issue) says.

The 1,000-issue limit of a collection bounds its report, not the work: an input that passes, or fails only near its end, is still checked item by item, so a large one costs its size. [Limits](docs/limits.md) says how to bound its size and its depth.

## 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 first validator

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

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

signup({ name: "  Ada ", email: "ada@example.com" }); // { ok: true, value: { name: "Ada", email: "ada@example.com" } }
signup({ name: "A", email: "nope" }); // { ok: false, ... }: "too_small" for name, "invalid_format" for email
```

These pages follow one example: the sign-up of a web app. It starts here as two fields and, like every sign-up form ever built, grows a little on each page. Each page adds what that page teaches, such as a form with a password check, a JSON request body, the server's configuration and the messages a user reads.

## Reading path

Read the pages in this order to learn the package from start to end, or jump to the one you need:

1. [Getting started](docs/getting-started.md): what a validator is, how to read its result, and the validators for strings, numbers, dates, fixed values and objects.
2. [Forms](docs/forms.md): rules across fields, optional fields and defaults, errors per field, files, and the types of a form.
3. [Request bodies](docs/request-bodies.md): JSON text, lists and dictionaries, a choice between shapes, and recursive data.
4. [Text input](docs/text-input.md): numbers, flags and dates that arrive as text, from environment variables, query strings and form fields.
5. [Issues and messages](docs/errors.md): what a failed result holds, the built-in codes, and the text a person reads, in English, in Portuguese or in your own words.
6. [Custom validators](docs/custom-validators.md): rules, formats and types of your own, asynchronous rules, and how to test them.
7. [Formats](docs/formats.md): every built-in format, such as `email`, `url`, `ip` and `datetime`, what each accepts and what it produces.
8. [Shaping values](docs/shaping-values.md): changing a value, writing it back, marking its type, describing it, and deriving one object from another.
9. [Standard Schema](docs/standard-schema.md): give a validator to any library that accepts a Standard Schema.
10. [JSON Schema](docs/json-schema.md): write a validator as a JSON Schema, and read what a validator is made of.
11. [Integrations](docs/integrations.md): use a validator with react-hook-form, TanStack Form, tRPC, Hono, the AI SDK, the Model Context Protocol, OpenAPI, zod and valibot.
12. [Limits](docs/limits.md): what input that may be hostile costs, the limits of recursive data, and how to check a schema for the bounds it needs.

To look up one export, use the [API reference](docs/reference/index.md), generated from the source. The [changelog](docs/changelog/index.md) lists every release and the migrations between them.

<!-- Source: docs/getting-started.md -->

# Getting started

This page is the foundation the others stand on, which is why it is the long one: how a validator is made and called, what it returns, and the validators for each basic kind of value. By the end of it you can write the first version of the sign-up: an object with a name, an email address and a password, and the TypeScript type that comes from it.

## A validator is a function

Every validator takes any input and returns a result. You create a validator by calling a function, with options if you want them. 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, ... }, code "too_big"

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

The `port` line runs once, when the module loads, and gives a function you can reuse for every value. To check a single value once, call both at once: `number({ int: true })(input)`.

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

### The order of the arguments

Every validator takes its arguments in the same order:

1. Its own arguments, such as the shape of an object or the item validator of an array.
2. An options object, such as `{ min: 3 }`.
3. Any number of checks, which are rules for rarer cases, such as `string({ min: 3 }, startsWith("a"))`.

The options can be left out when there are checks: `string(startsWith("a"))`. Every validator that reports an issue of its own also takes a `message` option, which [Issues and messages](docs/errors.md#wording-one-validator) explains.

### Mistakes in options throw at once

Options are read once, when the validator is created. An option that makes no sense is a mistake in your code, not in the input, so the factory throws a `RangeError` or a `TypeError` at that point, and the program fails where the mistake is. A crash at startup is rude, but it beats a schema that quietly accepts everything for six months. These throw:

- A value no input can satisfy: a negative length, a `NaN` bound, or limits that contradict each other, such as `{ min: 5, max: 2 }`.
- Options that are not a plain object, such as `string(/^a/)` where `string(pattern(/^a/))` was meant. Ignored, they would accept every string.
- A child that is not a function, such as `object({ name: undefined })` after an import that resolved to nothing. Every composer checks the validators and callbacks it is given, and names the wrong one.
- An option name the validator does not know, such as `string({ mni: 2 })`, which throws `TypeError: Unknown option mni`. Ignored, it would leave unset the limit it was meant to set. TypeScript reports such a name too, but not for options read from configuration, typed loosely or written in JavaScript.

An option given as `undefined` is no option. So a limit that may be missing can be passed directly: `string({ max: limit })`.

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

When something is wrong, the validator reports every problem it can find, not only 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 value that failed, and `params` that describe what was wrong. [Issues and messages](docs/errors.md) describes them in full.

### Invalid input does not throw

Invalid input is an expected outcome, not a bug: a sign-up form sees typos about as often as it sees sign-ups. So a validator returns it in the result. If you would rather stop the program, that is one line: `if (!result.ok) throw new Error(...)`, or [`assert`](docs/errors.md#throwing-for-invalid-configuration), which writes the message for you.

One kind of input can still throw: input that runs code of its own while it is read, such as a getter or a `Proxy` trap that throws. That exception propagates, as one from your own callback does. Data parsed from JSON has no such code. Two validators differ:

- [`objectLike`](#objects-that-are-not-plain) reports a property it could not read as an issue.
- `instanceOf` rejects the value with `invalid_type` when the prototype trap of the value, or the `Symbol.hasInstance` of the class, throws.

### The value can differ from the input

`result.value` is the value after the validator ran, which is not always the input you gave it:

- Clean-up you asked for is applied, such as `trim`, `case` or `clamp`.
- An object drops the properties its shape does not list.
- A format with several spellings of one value produces one of them. `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 see. The input itself is never modified.

## 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 name = string({ trim: true, min: 2, max: 100 });

name("  Ada  "); // { ok: true, value: "Ada" }
name("A"); // { ok: false, ... }, code "too_small"
```

| Option    | Meaning                                                                                                                                             |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `trim`    | Remove leading and trailing white space, as `String.prototype.trim` does, before the other options run and in the output.                           |
| `case`    | `"lower"` or `"upper"`: convert the string before the other options run and in the output, with the full Unicode mapping, the same in every locale. |
| `min`     | At least this many characters.                                                                                                                      |
| `max`     | At most this many characters.                                                                                                                       |
| `length`  | Exactly this many characters.                                                                                                                       |
| `message` | Wording for the issues it reports itself.                                                                                                           |

Clean-up always happens first, and every option and check then sees the cleaned string. So `string({ trim: true, min: 1 })` rejects a string of spaces. Some details:

- `trim` removes Unicode white space and line terminators, so a no-break space goes. A zero-width space (U+200B) is not white space and stays, so `string({ trim: true, min: 1 })` accepts it. The `nonBlank()` check rejects it.
- `case` can change the length the other options see: `ß` in uppercase is `SS`, and `İ` in lowercase is two code units.
- Lengths count UTF-16 code units, as `String.length` does, so an emoji can count as two, and a family of four as eleven.

Every option 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" }` |

Rarer rules are checks, given after the options: `pattern`, `startsWith`, `endsWith`, `includes`, `nonBlank`, `lowercase` and `uppercase`. [Checks](#checks) lists them.

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

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

## Numbers

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

```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"
```

| Option       | Meaning                                                                                                   |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| `clamp`      | `{ min, max }`: move a finite value into the range instead of rejecting it, before the other options run. |
| `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 holds exactly, that is, within `Number.MAX_SAFE_INTEGER`.                         |
| `message`    | Wording for the issues it reports itself.                                                                 |

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

`clamp` still rejects an infinity, as every `number` does, since an infinity more often means a fault earlier in the program than a value to clamp.

Bounds are checked when the validator is created:

- 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`.
- A bound that is not a number, or a `clamp` that is not a range with a number `min` and `max`, throws a `TypeError`.
- A `clamp` throws a `RangeError` for a `NaN` bound, a minimum of `Infinity`, a maximum of `-Infinity`, a minimum above its maximum, or a range whose every value 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, since that check would add size to every program that uses `number`. Such a validator rejects every input, which the first test of an input meant to pass will notice.

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

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

## 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 }`. To read `"true"` from a form or an environment variable, use `coerceBoolean`, from [Text input](docs/text-input.md).

`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, whole ones included, fail with `invalid_type`.

The options are bigints: `min` and `max` are inclusive, and `gt` and `lt` exclusive. They fail like the bounds of a number: `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`.

In `params`, a bound is written as its decimal digits, `"10"` for `10n`, 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`](docs/formats.md#datetime-and-time). To read a `Date` from text, use [`coerceDate`](docs/text-input.md).

The options `min` and `max` are `Date`s, both inclusive:

- A value that is not a `Date`, such as a date string, throws a `TypeError` when the validator is created.
- An invalid `Date`, or `min` after `max`, throws a `RangeError`.
- 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 in `params` is a `Date`.

## Fixed values

`literal(value)` accepts exactly one value, compared with `===`, and produces that value with its exact type. So `literal("admin")` produces the type `"admin"`, not `string`.

- Any primitive works, and this is how `null` and `undefined` are validated: `literal(null)`.
- It produces the declared value, not the input. The two differ only for zero: `===` matches `-0` to `0`, and `literal(0)` produces `0` for either.
- `NaN` is refused with a `RangeError` when the validator is created. `NaN === NaN` is false, so `literal(NaN)` could accept nothing, not even `NaN`.

`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 flag with three states.
- It also takes a TypeScript `enum`. It 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.
- These throw a `TypeError` when the validator is created: no values, a list with a hole such as `[, "a"]`, which would accept `undefined`, and a `Set` or another object of a class, which would read as an enum with no members. A list holding `NaN`, which no value equals, throws a `RangeError`.

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

const plan = oneOf(["free", "pro"]);
const version = literal("v1", { message: "Only v1 is supported" });

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

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 is reported as its digits, with nothing to tell it from a string.

Like every rule, `literal` and `oneOf` report what they were configured with, here the values they accept. So do not check a secret, such as an API key, with them or with a check such as `startsWith`: a failed check would print it, as [The issue](docs/errors.md#the-issue) explains.

## 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, such as an iframe, classes and async and generator functions included, and produces it unchanged. It fails with `invalid_type` and `{ expected: "function", received }`. It can check only that the value is a function, 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 without being checked, 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`. It fails with `invalid_type` and `{ expected: "instance of Class", received }`. An instance from another realm, such as an iframe, is not recognized; `date`, `map`, `set` and `object` accept values from any realm. A target that is not a function, such as an import that resolved to nothing, throws a `TypeError` when the validator is created.

## Checks

A check is a rule about a value that already has its type, given to a validator after its options:

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

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

A check runs only once the value has passed the validator's type test and every option:

- For a string or a number, once the input has the type and passed options 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 the validator reports the issue of each option that failed. 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.

The built-in checks are each imported on their own, and take their message as the last argument:

| 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.                              | `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 blank. 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`.                      | `invalid_value`, `{ type: "number", format: "multipleOf", value }` |
| `nonZero()`        | numbers | Anything but zero.                                                                                                          | `invalid_value`, `{ type: "number", format: "nonZero" }`           |
| `unique(by?)`      | arrays  | Distinct items, as [Request bodies](docs/request-bodies.md#array) explains.                                                      | `invalid_value`, `{ unique: true }`, at each repeat's index        |

Some details:

- The English message of `pattern` shows the expression, as in `Must match /^[a-z0-9_]+$/`. Where people read it, give the check a message: `pattern(regex, "Use letters, digits and _")`.
- For `nonBlank`, blank is white space, format characters (Unicode category Cf), the Hangul fillers and the blank braille pattern, which all render as space. So `""`, `"  "` and a zero-width space fail.
- 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 in its written form: the shortest text that reads back as the same number, 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 a multiple of `10`.
- A number computed in floating point is not always the decimal it looks like: `0.1 + 0.2` is `0.30000000000000004`, which is not a multiple of `0.1`.

`check(test, issue?)` makes a check of your own. [Custom validators](docs/custom-validators.md) covers it.

## Objects

`object(shape, options?, ...checks)` takes a shape, an object whose values are validators, and returns a validator for objects of that shape. Here is the first version of the sign-up:

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

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

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

signup({ name: "Ada", email: "ada@example.com", password: "correct horse battery" }); // ok
signup({ email: "nope" }); // { ok: false, ... }, code "invalid_type"
```

The second call reports three issues, each at its own path: `["name"]` and `["password"]` are missing, and `["email"]` is not an address. Issue paths lead down to the value that failed, so one inside a list has segments such as `["addresses", 0, "street"]`.

How `object` reads its input:

- It accepts plain objects only, created by `{}`, `Object.create(null)` or `JSON.parse`, in this realm or another, such as an iframe. Arrays, class instances, objects with a prototype of their own, `Map`s, `Date`s and `null` fail with `invalid_type` and `{ expected: "object", received }`.
- It reads only own properties, so a value inherited through the prototype never satisfies a required property.
- It validates every property, even after an earlier one failed.
- A property whose validator accepts `undefined`, such as one wrapped in `optional`, is optional in the type, and is left out of the output when the input does not have it. [Forms](docs/forms.md#optional-fields-and-defaults) covers `optional` and its defaults.
- 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 that `unknown()` lets through is the input's own object, and changing it in the output changes the input too.

A shape holds enumerable text keys only. `object` throws a `TypeError` when created with a symbol key or a key that is not enumerable, whose validator would never run.

### Keys the shape does not list

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. |
| A validator     | Validate each with it, and keep what it produces.                                       |

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

A passthrough object rejects `__proto__` because a kept `__proto__` key would become the prototype of any copy of the output made by assignment, such as `Object.assign({}, output)`. The issue is `invalid_key` with one issue inside, `invalid_value` with `{ reserved: true }`.

A validator given as `unknownKeys` checks each key the shape does not list, as `record` checks its values:

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

const settings = object({ name: string() }, { unknownKeys: boolean() });

settings({ name: "Ada", beta: true }); // { ok: true, value: { name: "Ada", beta: true } }
settings({ name: "Ada", beta: "yes" }); // { ok: false, ... }, code "invalid_type" at ["beta"]
```

- The output keeps what the validator produced for each key, after the listed properties, in the order of the input.
- Its issues are at the key's path, after those of the listed properties. They stop at the 1,000 issues a collection reports, as [Limits](docs/limits.md#a-failing-input-has-a-bounded-cost) describes.
- An unlisted `__proto__` is rejected with the same `invalid_key` issue, and its value is still validated, as in a `record`.
- The object is asynchronous when the validator is.
- The type gains an index signature for the other keys. It holds what the validator produces and what each listed property holds, because TypeScript checks the listed properties against the index signature too. So `{ name: "Ada", beta: true }` can be written as a value of the type, and an unlisted key that holds a string is not caught by the type, only by the validator.

### Objects that are not plain

`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 use `object` for data.

```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: "" }); // { ok: false, ... }, code "too_small"
```

- It accepts every object that is not an array: a plain object, a class instance, a `Map`, a `Date`. Anything else, a function included, fails with `invalid_type` and `{ expected: "object", received }`.
- It reads each listed property as `input[key]`, so a property 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.
- The output is a new plain object that holds the listed properties only, without the ones whose value is `undefined`. There is no `unknownKeys` option.
- Its checks run once every property has passed, and see the output.

To choose between instances by a property, put the `objectLike` validators in a `union`. `tagged` reads the tag of a plain object only, as [Request bodies](docs/request-bodies.md#tagged) explains.

## The type a validator produces

`Infer<typeof validator>` is the TypeScript type a validator produces, for a synchronous or an asynchronous validator. The validator is the single source of truth, so there is no separate interface to keep in step with it. In the sign-up above, `Signup` has a required `name`, `email` and `password`, and an optional `age`.

A validator can also accept a type that differs from what it produces, such as text it reads as a number. `InferInput` reads that type, which a form holds: [Forms](docs/forms.md#the-types-of-a-form) covers it.

## Asking only whether a value passes

When you only need a yes or no, use `is(validator, input)`, which returns a `boolean`:

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

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

isPort(8080); // true
```

`is` does not narrow the type of its input, and `isPort` is no type guard, for the same reason. A type guard also says that a value that fails is not of the type, which is false of most validators: `string({ min: 3 })` refuses `"ab"`, which is a string. Call the validator and read `result.value` where you want the typed value.

You can wrap `is` in a type guard of your own when the validator accepts every value of the type, as `string()` with no options does: `(input: unknown): input is string => is(string(), input)`. `number()` is not such a validator, since it refuses `NaN`.

`is` accepts synchronous validators only, and throws a `TypeError` for one that returns a promise.

## Sync and async

A validator is synchronous unless something it runs is asynchronous. Checking a name 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 asynchronous rule is an `AsyncValidator`, its result must be awaited, and the compiler stops you from reading it as if it were ready. Every other validator stays synchronous and pays nothing for the asynchronous ones. [Custom validators](docs/custom-validators.md#asynchronous-rules) has an example.

## Next

The sign-up now has a shape and a type. Next it meets a browser: [Forms](docs/forms.md) turns it into a form, with a password typed twice, optional fields with defaults, an avatar file, and errors shown beside each field.

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

# Forms

A form is where invalid input is most common, since a person types it on a phone, in a hurry, with autofill helping in its own way. It is also where that person needs to know what to fix. This page grows the sign-up into a form: a password typed twice, optional fields with defaults, an avatar file, and errors shown beside each field. It ends with the types a form library needs.

## Fields that must agree

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

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

const passwords = object(
  { password: string({ min: 12, max: 200 }), confirm: string({ max: 200 }) },
  check((data) => data.password === data.confirm, { path: ["confirm"], message: "Passwords must match" }),
);

passwords({ password: "correct horse battery", confirm: "nope" }); // { ok: false, ... }, code "custom"
```

`data` needs no annotation: its type comes from the object. [Custom validators](docs/custom-validators.md#adding-a-rule-with-check) explains what else a check can report.

### A rule that does not wait for the other fields

That check says nothing while any property fails, since there is no whole object to give it. On a form that is often too late: with the name still empty, the user learns the passwords differ only after fixing everything else.

`checkFields(keys, test, issue?)` is a check that names the properties it needs. It runs once those properties have passed, whether the others did or not, and its test is given an object of those properties alone:

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

const signupForm = object(
  {
    name: string({ trim: true, min: 2, max: 100 }),
    email: email(),
    password: string({ min: 12, max: 200 }),
    confirm: string({ max: 200 }),
  },
  checkFields(["password", "confirm"], (data) => data.password === data.confirm, {
    path: ["confirm"],
    message: "Passwords must match",
  }),
);

signupForm({ name: "", email: "ada@example.com", password: "correct horse battery", confirm: "nope" });
// { ok: false, ... }, code "too_small"
```

That call reports two issues: the one of `name`, and "Passwords must match" at `confirm`. Some details:

- 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 others, so the test cannot come to rely on one that may have failed. A named property 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 validation still gives one result. When another property waits, as one that asks a server does, the test of `checkFields` runs as soon as it can, and its issue comes with the rest once every property has answered.
- A name the object does not have throws a `TypeError` when the object is created, since it is a misspelling. So does 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.

## Optional fields and defaults

Three wrappers accept an absent value as well as what the validator inside accepts:

- `optional(validator)` accepts `undefined`.
- `nullable(validator)` accepts `null`.
- `nullish(validator)` accepts both.

The accepted value is passed through, and every other value goes to the validator inside. So `optional` does not treat `null` or the empty string as absent. The 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` is optional in the type, and is left out of the output when the input does not have it.

### Defaults

`optional(validator, value)` replaces `undefined` with a default, and gives every other value to the validator. So an invalid value is still rejected, and only a missing one is replaced:

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

const plan = optional(oneOf(["free", "pro"]), "free");
const interests = optional(array(string({ max: 50 }), { max: 10 }), () => []);

plan(undefined); // { ok: true, value: "free" }
plan("gold"); // { ok: false, ... }, code "invalid_value"
```

Inside an `object`, a property with a default is always present in the output type. Some rules:

- The default is trusted, and is not validated.
- A primitive is used directly. A function is called each time, with no argument, to make the default.
- An array or an object must come from a function, such as `() => []`. One value would be shared by every result, and a change to one would show up in the next: a bug that takes a week to find and one line to fix. The types reject it, and `optional` throws a `TypeError` when it is given one from JavaScript.
- For the same reason, a default that is itself a function, such as a callback for `func()`, is returned from one: `optional(func(), () => noop)`. Given `noop` directly, `optional` would call it and use what it returns, so the types reject it whenever the validator inside can produce a function.
- The type assumes the validator never produces `undefined` for a value that is present, which every built-in validator keeps. A `transform` that returns `undefined` is let through, and the default replaces only a missing value.

## Errors beside each field

`flatten` groups the messages of a failed result by field, which is what a form shows:

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

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

const result = signupForm({ name: "A", password: "correct horse battery", confirm: "nope" });
if (!result.ok) {
  flatten(result.error).fieldErrors; // { name: ["Must be at least 2 characters"], confirm: ["Passwords must match"] }
}
```

Issues at the root of the value go to `formErrors`, and the rest are keyed by their path in `fieldErrors`. [Issues and messages](docs/errors.md#errors-for-a-form) covers `flatten` in full, and how to word the messages in another language.

## 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 signupUpload = formData(
  object({
    name: string({ trim: true, min: 2, max: 100 }),
    avatar: optional(file({ maxSize: 1_000_000 }, contentType(["image/png", "image/jpeg"]))),
  }),
);

export const readSignup = async (request: Request) => {
  const form = await request.formData();
  const [avatar, ...others] = form.getAll("avatar");
  if (others.length === 0 && avatar instanceof File && avatar.name === "" && avatar.size === 0) {
    form.delete("avatar");
  }
  return signupUpload(form);
};
```

A file input with no file chosen still sends a part, because a browser's way of sending no file is to send an empty one: a `File` with no name and no content. `optional` sees a value, so `file` accepts it and `contentType` rejects it, since it holds no image. `readSignup` removes that part first, so a sign-up without an avatar passes.

### `formData`

`formData(validator, options?, ...checks)` reads a `FormData`, from this realm or another, into an object of its fields, each a string or a `File`. It then produces what `validator` makes of that object.

- A key given more than once fails at its path with `invalid_key`, unless the `repeated` option is set. A check that saw one of the two values while the server read the other would pass a value nobody checked.
- With `repeated: true`, every key gives 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 is 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. `toJsonSchema` and `encode` refuse `formData`, as they refuse 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. The options never read the file's content, 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`. To check what the file holds, use `contentType`.

[`audit`](docs/limits.md#checking-a-schema-for-the-bounds-it-needs) reports a `file` without a `maxSize`, since the program holds whatever size it accepts.

### `contentType`

`contentType(types, message?)` is a check for `file`. It 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 type when the check is made.
- The first bytes are the signature of a format, not proof that the rest is well formed. A program that decodes the file still handles one it cannot decode.
- Reading is asynchronous, so a `file` given this check is an `AsyncValidator`.
- 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 }`. A file that cannot be read fails with `{ unreadable: true }`.

## The types of a form

A form holds what the user typed, before it is validated. That is the validator's input type, which differs from the type it produces wherever the validator changes its value. `InferInput<typeof validator>` reads the input type, and `Infer` the output:

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

const preferences = object({ age: coerceNumber(), plan: optional(oneOf(["free", "pro"]), "free") });

type Preferences = Infer<typeof preferences>; // { age: number; plan: "free" | "pro" }
type PreferencesInput = InferInput<typeof preferences>; // { age: string | number; plan?: "free" | "pro" | undefined }
```

The two types differ for:

- `optional` with a default, whose input may be absent.
- The `coerce` validators, which accept text, as [Text input](docs/text-input.md) explains.
- `transform`, and `pipe`, which accepts what its first validator accepts.
- `json`, which accepts text, and `searchParams`, which accepts text or a `URLSearchParams`. The type of `searchParams` is `string | Iterable<[string, string]>`, so that it names no global of one runtime.
- `fallback`, which accepts anything.

Clean-up is no 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 never limits the argument. A validator you write by hand has the input type `unknown`, unless its type says otherwise, as `Validator<number, string>` does for one that reads a number from text.

A form library that takes a [Standard Schema](docs/standard-schema.md) reads both types from the validator. [Integrations](docs/integrations.md#forms) shows react-hook-form and TanStack Form.

## Next

The sign-up form is ready for the browser. The server is less trusting: it receives the same data as a JSON body, from any client at all, and often more than a form sends: lists, dictionaries and shapes that vary. [Request bodies](docs/request-bodies.md) covers those.

<!-- Source: docs/request-bodies.md -->

# Request bodies

A server receives what any client chose to send, and clients have range. This page covers the shapes a request body holds beyond one flat object: JSON text, lists, dictionaries, a choice between shapes, and data that nests inside itself. The sign-up API gets a list of interests, a referral that can take two shapes, and a body that is still JSON text.

## JSON text

`json(validator?, options?, ...checks)` accepts text that holds JSON, parses it, and gives the parsed value to `validator`:

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

const signupBody = pipe(
  string({ max: 100_000 }),
  json(
    object({
      name: string({ trim: true, min: 2, max: 100 }),
      email: email(),
      interests: array(string({ max: 50 }), { max: 10 }, unique()),
    }),
  ),
);

signupBody('{"name":"Ada","email":"ada@example.com","interests":["math"]}'); // ok
signupBody("{"); // { ok: false, ... }, code "invalid_format"
```

- 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`.
- Issue paths from `validator` are relative to the parsed value.
- A function in the first place is the validator, so checks need one: `json(object(shape), {}, check(...))`.
- Anything else in the first place but options throws a `TypeError` when created, `undefined` included. So a validator that is missing, such as one read from a key that does not exist, never makes a `json` that accepts any JSON.

The text is parsed by `JSON.parse`, so two of its rules apply:

- A key given twice keeps its last value: `{"a":1,"a":2}` gives `{ a: 2 }`.
- A number beyond what a double holds exactly loses digits: `12345678901234567890` becomes `12345678901234567000`, an ID that now belongs to someone else. Send such numbers as strings, and read them with `coerceBigint`.

The `string({ max: 100_000 })` in front bounds the text before it is parsed. [Limits](docs/limits.md) explains why a body needs a bound like it.

## Lists and other collections

Every collection validator takes the validator for its items, then options and checks. It checks every item, and reports each issue with a path that leads through the item's position.

The size options are `min`, `max` and `length`, all non-negative integers, and `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"`. `exact: true` is added for `length`.
- A wrong size is reported at once, without validating the items, so the items of a huge input are never validated only to be rejected. A `record` still lists the input's keys, to count them.
- A size that is not a number, such as `"3"`, throws a `TypeError` when the validator is created. A number that is not a non-negative integer throws a `RangeError`, and so do limits no size can satisfy together, such as `min` above `max`, or a `length` outside them.

A collection also stops once its items have reported 1,000 issues. [Limits](docs/limits.md#a-failing-input-has-a-bounded-cost) explains that limit.

### `array`

`array(item, options?, ...checks)` accepts arrays whose every item passes `item`, and produces a new array of what `item` produced. It takes `min`, `max` and `length`.

A sparse array's holes are validated as `undefined`, so the work grows with the array's `length`, not with the items it holds. Give untrusted input a `max`, which is checked before any item.

The `unique(by?, message?)` check rejects repeats:

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

const interests = array(string({ trim: true, min: 1, max: 50 }), { max: 10 }, unique());

interests(["math", "music"]); // ok
interests(["math", "math"]); // { ok: false, ... }, code "invalid_value"
```

- It compares the validated items the way a `Set` does, or the key `by` returns for each item: `unique((user) => user.id)` makes ids unique.
- Each repeat is reported at its own index, as `invalid_value` with `{ unique: true }`, 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 without `by`, `unique()` would find no repeat among them. Its types accept it only for an array of primitives; an array of objects, lists or dates needs `by`, such as `unique((day) => day.getTime())`.

### `tuple`

`tuple(items, options?)` accepts an array of fixed length in which each position has its own validator, and produces a tuple type:

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

- Without `rest`, the array must be exactly as long as `items`. A wrong length fails with `too_small` and `{ minimum, type: "array", exact: true }`, or `too_big` and `{ maximum, type: "array", exact: true }`.
- The `rest` option is a validator for every position after the fixed ones, so the array can be longer. It must still have at least as many items as `items`, and fails with `too_small`, without `exact`, when it has fewer.
- `max` is allowed only with `rest`, and caps the whole length, 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`.

### `record`

`record(key, value, options?)` accepts a plain object used as a dictionary: any number of keys, each passing `key`, each value passing `value`.

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

const scores = record(string({ max: 50 }), number(), { max: 100 });
const hours = record(oneOf(["mon", "tue"]), number()); // { mon?: number; tue?: number }
```

- 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. 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 key as `invalid_key` with one `invalid_value` issue inside, `{ unique: true }`, instead of dropping a value.
- The output type has every key when `key` produces `string`, and is partial when `key` produces a fixed set of strings, such as `oneOf(["mon", "tue"])`.
- A `__proto__` key, which parsed JSON can hold, is reported as `invalid_key`, with one `invalid_value` issue inside, `{ reserved: true }`. That holds whether the input has the key 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)`.
- `min`, `max` and `length` count keys, so `record(string(), number(), { max: 100 })` rejects a dictionary of more than 100 keys before it checks any of them.

### `set` and `map`

`set(item, options?)` accepts a `Set` and produces a new `Set` of the validated values. `map(key, value, options?)` accepts a `Map` and produces a new `Map`. Arrays and plain objects are not accepted for them. Both take `min`, `max` and `length`.

- In a set, a path leads through the value's position, in iteration order.
- In a map, a path leads through the entry's key when the key is a string, and through 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`. So is a key that another entry already has once the key validator changed it.
- 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, instead of being merged. So the output never holds fewer values than the size options allow.

## A choice between shapes

### `union`

`union(options)` accepts a value that passes any one of several validators, and produces what the first one that accepts it produced. First come, first served, so put the more specific options first:

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

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

union([coerceNumber(), string()])("5"); // { ok: true, value: 5 }
union([string(), coerceNumber()])("5"); // { ok: true, value: "5" }
```

A value that no option accepts fails with one `invalid_union` issue at the value's own path. Its `params.issues` lists, for each option in order, the issues that option found, with paths 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, without the issues of its own options. [Issues behind an issue](docs/errors.md#issues-behind-an-issue) explains why. To see those issues, call the inner union on the value.
- Every option runs until one accepts the value. So in a recursive union of objects, every option reaches the children. Use `tagged` for objects told apart by a property, which validates only the matching variant.
- A limit of `lazy` that stopped the validation is kept, as [Limits](docs/limits.md#recursive-data) explains.

### `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, and the variant validates the rest of the input. A failure reports that variant's own issues, not a list of everything that did not match.

The sign-up accepts a referral from a friend or from a campaign:

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

const referral = tagged("source", {
  friend: object({ email: email() }),
  campaign: object({ code: string({ min: 3, max: 20 }) }),
});

type Referral = Infer<typeof referral>;
// { source: "friend"; email: string } | { source: "campaign"; code: string }

referral({ source: "campaign", code: "SPRING" }); // { ok: true, value: { source: "campaign", code: "SPRING" } }
referral({ source: "ad" }); // { ok: false, ... }, code "invalid_union"
```

**A variant must not list the tag.** In some other libraries each variant is written `object({ source: literal("friend"), ... })`. Here a variant is given the input without its tag, so a variant that lists the tag 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. Reading it and narrowing on the tag work, but TypeScript rejects writing such an object 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 `referral.source` 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. A variant that produces any other value that is not a plain object at run time, such as a `Date` or a class instance from a `transform`, throws a `TypeError` that names the variant, since it is a mistake in the schema. `tagged` reads the tag of a plain object only, so it rejects a class instance before any variant sees it. To choose between instances, put `objectLike` validators in a `union`.

### `intersection`

`intersection(left, right)` accepts a value only when it passes both validators. It 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. A conflict inside one is reported at the entry's key when it is a string, and otherwise 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 of the same moment.

Where the outputs differ otherwise, as `"  ab "` does when one side trims it and the other uppercases it, no value satisfies both. Each such place fails with `invalid_intersection` at its path, instead of one side silently winning.

The merged object is new. So an own `__proto__` key that one side passed on unchecked, as `unknown()` does, is reported as `invalid_key` with one issue inside, `{ reserved: true }`, as `record` reports it. Two cases keep the key:

- **A side whose shape lists `__proto__` at that place, and made the value there.** The shape has to be the last thing that touched the value. It is not when something after it made a new value:
  - A shape inside a `transform`. The value is what the transform's function returned, not what the shape checked.
  - A shape in a `pipe` step that a later step replaces with a new object.
  - The key validator of a `map` entry. It made the key, not the entry's value.
- **A value both sides passed on whole,** such as through `unknown()` on both. It is the input's own object, and is kept unchanged.

So `intersection(object({ ["__proto__"]: unknown() }), unknown())` keeps a `__proto__` key, since the left shape lists it and made the value. Wrap that `object` in a `transform` and the key is reported instead, unless the transform hands back the input object itself. The computed key matters: written as `{ __proto__: unknown() }`, the literal sets the shape's prototype and lists no key at all. Yes, really.

Both validators see the whole input, so two `object`s with `unknownKeys: "strict"` pass together only when every key of the input is listed in both shapes: each rejects the keys only the other lists. To combine strict shapes, spread them into one: `object({ ...named, ...aged }, { unknownKeys: "strict" })`.

## Recursive data

`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({ max: 100 }),
  children: array(
    lazy(() => category),
    { max: 50 },
  ),
});

category({ name: "Science", children: [{ name: "Math", children: [] }] }); // ok
```

Recursive input can be deep enough to overflow the stack, or wide enough to make the work grow faster than the input. `lazy` has two limits for those cases:

- `maxDepth`, 128 by default: the most levels of `lazy` that may be open at once. Deeper input fails with `too_big` and `{ maximum, type: "depth" }`.
- `maxCalls`, 10,000 by default: the most calls one `lazy` may make in one validation. Past it, each call fails with `too_big` and `{ maximum, type: "calls" }`.

Input nested thousands of levels deep, or a cyclic object, comes back as one of those failures and does not throw, as long as `maxDepth` fits the stack. A schema with many validators at each level can overflow the stack of a worker first, so [lower `maxDepth`](docs/limits.md#maxdepth) for it there. [Limits](docs/limits.md#recursive-data) explains both limits, when to change them, and how to build a recursive schema so that they hold.

## Next

The body of a request is JSON, but much of what a server reads is text: a query string, an environment variable, a form field. [Text input](docs/text-input.md) reads numbers, flags and dates from text.

<!-- Source: docs/text-input.md -->

# Text input

Much of the data a program receives is text pretending to be something else. Every environment variable, query-string parameter and text field of a form is a string, whether it stands for a port number, a yes-or-no flag or a date. A file field is a `File` instead, which [Files](docs/forms.md#files) covers. This page covers the validators that read a value from such text, and two places it comes from: a query string and the environment. The sign-up server reads its configuration from the environment, and its sign-up page reads an invitation from the query string.

A strict validator such as `number()` rejects `"8080"`, because it is a string. That is correct, and no help here. 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 coercing validators

There is one for each type that commonly arrives as text:

| 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 that hold a decimal number, such as `"42"`, `" 3.5 "`, `"-1"`, `".5"` or `"5."`.                       | `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 that hold 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`         |

Each takes the options of its strict version and applies them to the converted value. `coerceDate` also takes one option of its own, `zoneless`, described under [Dates](#dates). A bad option throws when the validator is created, so `coerceNumber({ min: Number.NaN })` fails at once, as `number({ min: Number.NaN })` does. They take checks and a `message` option too, and the message also words input that cannot be converted.

`coerceNumber` accepts a number written with no digits on one side of the dot, such as `".5"` or `"5."`, since that is how people type.

Surrounding white space is ignored in the text `coerceNumber`, `coerceBigint`, `coerceBoolean` and `coerceDate` read. That is white space 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 unchanged; trim it with `coerceString({ trim: true })`.

## What is not converted

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

- **Strings:** `NaN` and `Infinity`, which a failed numeric conversion earlier in the program leaves behind, and `null`, `undefined`, objects, arrays, functions and symbols.
- **Numbers:** empty strings, `"1e3"`, `"0x10"`, `"1,5"`, `"Infinity"` and `"NaN"`; text that holds a whole number beyond `Number.MAX_SAFE_INTEGER`, which would silently lose digits (use `coerceBigint` for those); 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`, and 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 `"03/04/2026"`, whose reading depends on the runtime, and whose month depends on the continent, 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 English message names both types, as in "Cannot convert string to a number". The value itself is never in the issue.

A value that converts but then breaks an option fails with that option's own code, as for the strict validator.

A value that already has the type is given to the strict validator unchanged. 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`.

Some details of the conversions:

- The text `"-0"` is read as `0`. The number `-0` is passed through unchanged, as `number` does.
- A decimal is read as the nearest double, so `"0.30000000000000004999"` is `0.30000000000000004`.
- `coerceBigint` takes longer than linear time to convert long text. [`audit`](docs/limits.md#checking-a-schema-for-the-bounds-it-needs) asks for a bound on each side of it, which also limits the digits it converts.

## Dates

`coerceDate` reads date text in these ISO 8601 forms:

- A date, `YYYY-MM-DD`, alone. It is read as midnight UTC.
- A date followed by `T` or a space, then a time, `HH:MM` or `HH:MM:SS`, with an optional fraction of a second after the seconds.
- Then a 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`. A converter reads what programs and people commonly write, while a [format](docs/formats.md#datetime-and-time) checks one exact spelling.

A date-time without a zone fails. `2026-09-28T14:30` names a time on some clock, not a moment, and the package will not guess whose clock. Read in any one zone, it would give everyone elsewhere a moment hours off, without a word. When you know such text is written in UTC, pass `zoneless: "utc"`, the only value that option takes:

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

coerceDate()("2026-09-28T14:30"); // { ok: false, ... }, code "invalid_type"
coerceDate({ zoneless: "utc" })("2026-09-28T14:30"); // ok
```

An HTML `datetime-local` input sends text without a zone, and without seconds when they are zero. Add the user's offset to its value before converting it.

Either way, the result does not depend on the time zone of the machine. Some more rules:

- A fraction of a second beyond milliseconds is cut, not rounded, the same way on every runtime.
- A timestamp with a fraction of a millisecond is rejected, not cut.
- Leap seconds (`23:59:60`) are rejected, since a `Date` cannot hold one and prefers to believe they never happen.

## A query string

`searchParams(validator, options?, ...checks)` reads a query string, with or without its `?`, or a `URLSearchParams`, from this realm or another, into an object of its decoded parameters. It then produces what `validator` makes of that object. Each value is text, so the validator inside uses coercion for anything else:

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

const invitation = searchParams(object({ invite: optional(string({ max: 64 })), step: coerceNumber({ int: true, min: 1 }) }));
invitation("?invite=AB12&step=2"); // { ok: true, value: { invite: "AB12", step: 2 } }

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

- It reads `+` as a space and decodes escapes.
- A key given more than once, as in `?id=1&id=2`, fails with `invalid_key` at `[key]`, unless `repeated` is set. A check that saw one of the two values while a server read the other would pass a value nobody checked.
- With `repeated: true`, every value of every key is a list, so validate it with `array`, as in `array(coerceNumber())`.
- Anything else fails with `invalid_type` and `{ expected: "query string", received }`.

The `query` part of [`url`](docs/formats.md#parts-of-an-email-or-a-url) reads parameters the same way, but only to check them: the value it produces is still the whole URL.

## The environment

Combine coercion with `object` and with `optional` and a default to turn an environment 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`, so `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 every other validator rejects.

The environment is configuration, so an invalid one is a mistake to stop at: a server that refuses to start is easier to debug than one that starts and misbehaves at 3 a.m. [`assert`](docs/errors.md#throwing-for-invalid-configuration) returns the value or throws an error that names the variable.

## Cleaning text first

To clean text before converting it, put a strict validator in front with `pipe`: `pipe(string({ trim: true }), coerceNumber())`. [Shaping values](docs/shaping-values.md#pipe) covers `pipe`.

For text that holds JSON, use [`json`](docs/request-bodies.md#json-text), which parses the text and validates the result.

## Next

Every page so far has shown `{ ok: false, ... }` and politely skipped the `...`. [Issues and messages](docs/errors.md) opens it: what a failed result holds, and how to turn its issues into text a person reads.

<!-- 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 them into text for a person: the messages of the sign-up form, in English or in Portuguese, and the error a server throws for a bad configuration.

Validators do not write text when they fail, unless you give one a [`message`](#wording-one-validator). They report the facts and leave the prose to whoever shows it, so a program that never shows an issue carries no wording.

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

- The order follows the schema, not the time each rule took. A strict `object` reports its unrecognized keys first, then the issues of its properties in the order the shape lists them.
- A collection stops at 1,000 issues, as [Limits](docs/limits.md#a-failing-input-has-a-bounded-cost) explains.

## The issue

| Field     | Meaning                                                                                                                                     |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`    | A stable string that names 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 what the schema was configured with, such as a limit, 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 in issues:

- A path leads through the keys of the input, so a `record` or `map` issue names the key it belongs to.
- A strict `object` reports each key it does not recognize, in its path and in `params.key`.

So do not put secrets in key names you validate. A key is also text the sender chose. `englishMessages` puts it inside the message as a quoted string, as in `Unrecognized key "…"`, with quotes and line breaks escaped. `formatPath` quotes it the same way in the field names of `flatten`. So a key cannot break a log line. Escape both when you put them into HTML.

The same goes for what your own schema names: a rule reports what it was configured with, in `params` and in the English message. That includes the values `literal` and `oneOf` accept, the text of `startsWith`, `endsWith` and `includes`, and the expression of `pattern`. So do not write a secret, such as an API key, into a schema: a failed check would print it.

### Writing a path

`formatPath(path)` writes a path for display:

```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 is quoted in brackets when it is empty or holds `.`, `[`, `]`, `"`, a control character such as a line break, or a line separator, with those characters escaped. So a key named `a.b` and the path `a`, `b` never give the same text.

## Built-in codes

The set of codes is open: a custom validator reports whatever code it likes. These are the codes the built-in validators use. Each page lists the exact code and `params` beside the validators it covers.

| 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 failed part of `url` or `email`, `part` and `issues`, what the part validator found.                                                           |
| `invalid_value`        | The value has the right type, but a value that is not allowed.                                                                                                                                   | Depends on the rule, as the list below this table says.                                                                                                                                                                  |
| `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; 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 has; a key is given twice; or a key is `__proto__` where it would be copied over. | `issues`: what the key validator found, with paths relative to the key; for a repeated key, one `invalid_value` issue with `unique: true`; for `__proto__`, one with `reserved: true`. The issue's path ends at the key. |
| `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.                                                                                                                                 | Whatever the reporter set.                                                                                                                                                                                               |

The `params` of `invalid_value`:

- `expected` for a `literal`, and `options` for a list of values.
- For a number, `type` and `format` (`"int"`, `"safeInt"`, `"nonZero"` or `"multipleOf"`), with the step in `value` for `"multipleOf"`.
- `unique: true` for a repeat in an array or a set.
- `reserved: true` inside the `invalid_key` of a `__proto__` key.
- `unreadable: true` for a property of an `objectLike` input that threw while it was read, or a file `contentType` could not read.
- `content: true` beside `options` for a file whose first bytes are of none of the types `contentType` lists.
- `encodedSeparator: true` and `dotSegment: true` for the URL paths described in [Formats](docs/formats.md#paths-a-server-reads-otherwise).

A `__proto__` key is reported where it would be copied into a new object: in a `record`, among the unlisted keys of a passthrough `object`, and in an `intersection` whose sides passed it on unchecked.

`englishMessages` has no wording for `custom`, since a failed check carries nothing to word, so `formatIssue` says "Invalid value". Give a check a message where people read what it reports.

### The type names in `received`

`received` names kinds of value the same way everywhere: `null`, `array`, `nan`, `infinity`, `date`, `invalid date` for a `Date` that holds 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`. `englishMessages` words that "Expected a plain object; copy it first, as in { ...value }", since a copy, such as `{ ...process.env }`, is what passes.

## Turning an issue into text

`formatIssue(issue, messages?)` turns an issue into text, from a message map, `englishMessages` unless you pass another:

```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 text comes from the first of these that exists:

1. The issue's own `message`, which a validator's [`message` option](#wording-one-validator) or a check's message sets.
2. An entry for its `code` in the message map.
3. The entry `default` of that map, which words every code the map has no entry for.
4. The text "Invalid value".

`englishMessages` is a value of its own. A program bundles it when it calls `formatIssue` or `flatten`, even with another map, since it is their default. A program that never calls them does not bundle it. A program whose issues all carry their own `message` can pass `{}`. A map that is not an object, such as `null`, is a `TypeError`.

`formatIssue` words 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: validate it before you word it.

A validator also has a short English wording of its own, for the libraries that read it as a [Standard Schema](docs/standard-schema.md). That wording is not `englishMessages`, and that page describes it.

### Issues that hold other issues

Three codes hold 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 them with the issue behind them, so the message says why:

- `invalid_key` with the first issue the key validator found, as in "Invalid key: Must be at least 3 characters". A key given twice, in a query string or after a key validator made two keys the same, is "Must be given only once".
- A failed part the same way, as in "Invalid email address domain: Expected one of "company.com"".
- `invalid_union` with the first issue of the one option the input was meant for, when the input leaves exactly one candidate. An option is ruled out when all it found is that the input is not its type, or not one of the values a `literal` or `oneOf` accepts. With more candidates or none, it says "Does not match any of the allowed types". A limit of `lazy`, `maxDepth` or `maxCalls`, that an option hit comes before all of that: the message quotes the limit, since it stopped the validation whatever the input holds. The issues of each option are in `params.issues` for a message of your own. For example:
  - `union([string({ min: 3 }), number()])` given `"ab"`: `number()` found only that `"ab"` is not a number, so the string was meant, and the message is "Must be at least 3 characters".
  - `union([literal(""), email()])` given `"nope"`: `literal("")` found only that `"nope"` is not its value, so the message is "Invalid email address".
  - `union([string({ min: 3 }), email()])` given `"ab"`: both options take strings and both found more than a wrong type, so either could be meant, and the message is "Does not match any of the allowed types".

The issue behind is worded with the same map. So an override of `too_small` in a copy of `englishMessages` reaches the message of a key too. A quoted issue deeper than the value the outer issue is shown at starts with its path, as in "width: Must be at least 1" for the `width` of an object option.

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 holds issues of its own. So issues nest at most three deep, whatever composes them:

- 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.
- 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, keeps its code and path, without its options' issues.

Without this rule, a recursive schema would nest issues once for each level. Where the levels share a result, as the options of a recursive union do through one `lazy`, each path through that nesting would write the result out again, and a small input could give a result too large to serialize.

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

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

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

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

- It words every issue that validator reports itself, and every issue one of its checks reports without a message of its own.
- It words no issue 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, for a check that needs its own sentence. `string({ message: "Invalid username" }, pattern(/^\w+$/))` says "Invalid username" for either failure, never the pattern.
- A 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.

An issue that carries a message is worded by it first, before any message map. That is how one field gets its own sentence while a map words the rest.

## Built-in languages

Three maps ship with the package. Each is a value of its own, so a program bundles the languages it imports and no other, apart from the English that `formatIssue`, `flatten` and `standard` fall back to:

| 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"   |
| `spanishMessages`    | Spanish                          | "Debe ser como mínimo 18" |

All three word the same nine codes. `formatIssue`, `flatten` and `standard` take the map as an argument, and `assert` takes it as its `messages` option. 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"));
```

`portugueseMessages` writes numbers as Brazil writes them:

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

const below = number({ min: 0.1 })(0);
if (!below.ok) {
  formatIssue(below.error.issues[0], portugueseMessages); // "Deve ser no mínimo 0,1"
}
const step = number(multipleOf(0.01))(0.001);
if (!step.ok) {
  formatIssue(step.error.issues[0], portugueseMessages); // "Deve ser múltiplo de 0,01"
}
```

- A bound or a step uses a decimal comma.
- A long list of allowed values ends "e mais N", where English says "or N more".
- The messages about keys use the feminine, since "chave" is feminine: "Chave não reconhecida".

`spanishMessages` keeps the same three rules, with "y N más" and "la clave". Its decimal comma is how Spain and most of South America write a number. Where a point is the custom, as in Mexico, spread the map and override the codes whose wording holds a number: `too_small`, `too_big` and, for a step, `invalid_value`.

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 no `default`, since the last wording of `formatIssue`, "Invalid value", is English already.

## Rewording and localizing

A message map is an object from code to text:

- A string is used directly.
- 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, copy `englishMessages` with a spread, and override the codes you want. It is frozen, so it cannot be changed in place. To translate into a language that has no map here, write a map of your own:

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

Cover the codes your validators can report, which the tables above list, and give the map a `default` entry, as text or as a function of the issue. Without one, every code you leave out says "Invalid value", in English. A custom validator's own codes belong in the map too, or the issue can carry a `message`.

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. That holds in `formatIssue`, `flatten`, `assert` and `standard` alike, so a form never shows `undefined`.

### The wording of one code

Each code's English wording is also an export of its own. A program that can report only a few codes builds a map of those. One that words its issues only with [`assert`](#throwing-for-invalid-configuration) then bundles no other wording, while `formatIssue`, `flatten` and `standard` bundle all of `englishMessages` anyway, [as their default](#turning-an-issue-into-text):

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

## Errors for a form

`flatten(failure, messages?)` groups the messages of a failed result for display. Issues at the root go to `formErrors`, and the rest are keyed by their path, written by `formatPath`, 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. What it returns has the type `FlattenedErrors`.

`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 cost is that it has no methods: ask with `"name" in fieldErrors` or `Object.hasOwn(fieldErrors, "name")`, not `fieldErrors.hasOwnProperty("name")`.

## Throwing for invalid configuration

Some input is not expected to be invalid. An options object another developer passes to your function, or the environment of a server, is wrong only by mistake, and a mistake is best thrown where it was made, while the person who made it is still looking. `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, then its wording. Without a `subject`, it starts at the path. 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".
- **`assert` has no default map.** Without `messages`, an issue without its own message says "Invalid value", as in `TypeError: port: Invalid value`. The path still says which option is wrong. Pass `englishMessages`, a map of a few codes as above, or a `message` on each validator, as below, for more.
- The error's `cause` is the failure, so `error.cause.issues` lists every issue the validator reported, not only the first.
- It accepts synchronous validators only, and throws `TypeError: assert() needs a synchronous validator. Call the validator and await its result instead.` for one that returns a promise.
- An option it does not know throws, as for a factory: `TypeError: Unknown option subjct`.

```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:" }); // { port: 8080 }
assert(options, { port: 0 }, { subject: "createServer:" }); // TypeError: createServer: port: Invalid value
assert(options, { port: 0 }, { subject: "createServer:", messages: englishMessages }); // TypeError: createServer: port: Must be at least 1
```

### 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 a class instance or an `Error`, and reports a getter that throws with the message of the object. `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 every issue instead of throwing for the first.

## Sending a result elsewhere

A failed result is plain data, so it can be logged, sent to a client or stored. No issue the package reports holds a value of the input, so doing so does not leak what was submitted. An issue does name the input's keys, in `path`, and a check you write decides what its own issue holds. A result that passed holds the value itself.

A few values do not survive JSON unchanged:

- No issue holds a bigint, which `JSON.stringify` cannot write: 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` writes as ISO text. `englishMessages` words either form the same.
- A `literal` or `oneOf` of `undefined`, a symbol or an infinity holds that value, which JSON writes as `null` or leaves out. Word such issues before you send them.

## Next

The built-in validators and their messages cover the common cases. For a rule of your own, such as a name that must not be taken already, see [Custom validators](docs/custom-validators.md).

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

# Custom validators

The built-in validators cover common cases. A real sign-up has rules of its own: a name that is reserved, a username somebody already took, a product code your company invented years ago that nobody dares to change. This page shows how to write those. Neither kind needs registration:

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

The helpers `check`, `format` and `guard` build both on the same internals as the built-in ones. So what you build behaves 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 mustBeEven = check((n: number) => n % 2 === 0, "Must be even");

const seats = number({ int: true }, mustBeEven);
const username = string(
  { trim: true, max: 30 },
  check((name) => name !== "admin", { code: "reserved_name", message: "That name is reserved" }),
);

username("admin"); // { ok: false, ... }, code "reserved_name"
```

The test receives the value the validator produced, after its clean-up. It returns `true` when the value is acceptable. A check of the wrong type, such as a number check given to `string`, is a compile error.

The second argument is the issue to report when the test 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. It must return text, or the validator throws a `TypeError` when it reports the issue.
- 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 a code, 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 only its code is left, and `formatIssue` says "Invalid value". So 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 `username` above never tests a name longer than 30 characters. For an object, that means once every property has passed, so a check can compare them. [Forms](docs/forms.md#fields-that-must-agree) shows two passwords that must match, and [`checkFields`](docs/forms.md#a-rule-that-does-not-wait-for-the-other-fields), a check that runs before the other fields have passed.

### A check written by hand

A check can also be a function that returns nothing for a value it accepts, or the issues it found. Type it as `Check<T>`. That is how a check reports several issues at once, or chooses a path for each 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, max: 200 }, strongPassword);
```

Each issue it returns is an object, as `fail()` makes one, and its type is `ValidationIssue`:

- Anything else in the list, such as `null`, throws a `TypeError` when the check reports it.
- An issue without a `code` has code `custom`.
- Its `code` and `message` are text, and its `path` is a list of keys and indexes. A `path` that is not a list, such as `"confirm"`, throws a `TypeError` too.

Building issues with `fail()` checks the rest when they are made.

## 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`.
- A string the test rejects fails with `invalid_format`, and `params.format` set to `name`.
- A string the test accepts is produced as written.

```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, ... }
```

The test must answer at once. A test that returns a promise throws a `TypeError` when the validator runs, since a pending answer would pass every value. A rule that waits is a check, as [Asynchronous rules](#asynchronous-rules) shows.

`name` is part of the format's contract: callers branch on it and message maps word it, so choose it once: renaming it later breaks every caller that branched on it. [JSON Schema](docs/json-schema.md) writes no `format` for it, since JSON Schema does not know the name.

### 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 blob = guard("Blob", (input): input is Blob => input instanceof Blob);

const attachment = object({ content: blob({ message: "Choose a file" }) });
```

For a `File` from a form, use the package's own [`file`](docs/forms.md#file), which also checks the size and the type.

### 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/forms.md#the-types-of-a-form): `Validator<number, string>` is a validator that reads a number from text. Without it, the input type is `unknown`.

A validator written by hand works everywhere a built-in one does, such as inside `object`, `optional` and `pipe`. It has no `message` option or checks unless you write them, which is what the builders are for.

`fail` takes one or more issues. 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`.            |

`fail` throws a `TypeError` for no issue, since a failure with no issue says nothing. It also throws for a `path` that is not a list, such as `"confirm"` where `["confirm"]` was meant, which would be split into one segment per letter.

### A validator that returns no result

A validator that returns anything other than a result is a bug, and the package throws a `TypeError` that names where it is:

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

const profile = object({ name: string as unknown as Validator<string> });

profile({ name: { min: 2 } }); // TypeError: The validator at ["name"] returned no result. A factory is called first, as in string()
```

The common cause is a factory given where its validator belongs, as `string` instead of `string()`. Called with the input, a factory returns a validator, or throws for input that is no options object. The types reject that, so it happens in JavaScript, or past a cast. A validator written by hand that returns nothing, the input itself, or a failure without issues is reported the same way. Where there is no path, the message names the place instead: "The validator given to fallback", "The key validator of record", or `options[1]` of a `union`.

### A conversion that can fail

[`transform`](docs/shaping-values.md#transform) 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/text-input.md#dates) 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, max: 30 },
  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, where a promise has no `ok`: `!result.ok` would turn every sign-up away, and `result.ok === false` would let every one through. A check written by hand 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 separate validation, since the package cannot follow an `await` it did not make. A `lazy` it reaches counts its `maxCalls` again from zero, 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. 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` and `assert` need a synchronous validator.** They throw a `TypeError` for one that returns 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. Only `instanceOf` turns a throw into a failure: a `Symbol.hasInstance` of the class, or a prototype trap of the value, that throws fails the value with `invalid_type`, as `objectLike` reports a property it could not read.

## What you must not do

- **Do not put the input in an issue.** No `params` value, and 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. A library of validators that wants each one 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");
});
```

## Types

A library that exports validators of its own names them in its declarations. These are the types the package exports for that. Each is documented in its source, and listed in the [API reference](docs/reference/index.md).

The types you write most:

- `Validator<T, TInput?>` and `AsyncValidator<T, TInput?>`: a validator, synchronous or not. `AnyValidator` is either one.
- `Schema<T, TInput?>` and `AsyncSchema<T, TInput?>`: a validator a factory of this package made, which is also a [Standard Schema](docs/standard-schema.md). A validator written by hand is a `Validator` and not a `Schema`, since it has no `~standard`.
- `Check<T>` and `AsyncCheck<T>`: a check.
- `Infer` and `InferInput`: the type a validator produces, and the type that can pass it.
- `ValidationResult<T>`, which is `ValidationOk<T>` or `ValidationErr`. A `ValidationErr` holds a `ValidationFailure`, whose `issues` are `ValidationIssue`s.
- `ValidationIssueCode`, the built-in codes, open to any other string, and `ValidationPathSegment`, a key or an index of a `path`.
- `IssueInput`, an issue as `fail` takes it.
- `Message`, the text or function of a `message` option, and `MessageOptions`, the options every validator takes.

The options of each validator are named after it: `StringOptions`, `NumberOptions`, `BigintOptions`, `DateOptions`, `FileOptions`, `ArrayOptions`, `TupleOptions`, `ObjectOptions`, `LazyOptions`, `FormDataOptions`, `SearchParamsOptions`, `CoerceDateOptions`, `EmailOptions`, `UrlOptions`, `IpOptions`, `UuidOptions`, `DatetimeOptions`, `TimeOptions` and `Base64Options`. `SizeOptions` holds the `min`, `max` and `length` that `set`, `map` and `record` take. `AssertOptions`, `JsonSchemaOptions` and the rest belong to the pages of their functions.

The types of shapes, which name what a composer produces and accepts:

- `Shape`, the properties of an `object`, with `InferShape` and `InferShapeInput`.
- `InferRecord` and `InferRecordInput`, for a `record`.
- `InferTuple` and `InferTupleInput`, for a `tuple`.
- `Variants`, the variants of a `tagged` union, with `InferTagged` and `InferTaggedInput`.
- The types of `pick` and its kin, in [Shaping values](docs/shaping-values.md#reusing-shapes).

The machinery of the signatures, which you mostly meet in your editor's tooltips:

- `Factory<T, TOptions, TInput?>`, the factory a builder such as `format` returns: options first, then checks.
- `Rest<T, TOptions>` and `AsyncRest<T, TOptions>`, what follows a validator's own arguments: options, then checks.
- `Composed<TChildren, TOutput, TInput?>`, the validator a composer returns: synchronous while every child is.
- `LiteralValue`, a value `literal` takes. `EnumLike`, an object made by a TypeScript `enum`, which `oneOf` takes. `AnyFunction`, any function, for `func`. `Constructor`, a class, for `instanceOf`.

## Next

Many rules of your own are formats of text. Before you write one, check whether the package has it, since every email regex on the internet is wrong in its own way. [Formats](docs/formats.md) lists email addresses, URLs, IP addresses, dates and the rest.

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

# Formats

A format is a validator for a string of a particular shape, such as an email address or a URL. The sign-up needs one for its email, and many rules you might write yourself are a format the package already has. This page lists them, what each accepts, and the value each produces. It spends the most time on `email` and `url`, since an address or a link a server follows is where a careless check costs the most. Those sections are long because the specifications behind them are, and we read those so you would not have to.

Each format is imported on its own, so a program bundles the ones it uses and no other. Each takes a `message` option and checks.

## What a format produces

Most formats produce the string unchanged. So trim or lowercase it first with `pipe` when the input may need it:

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

A format whose meaning has several spellings produces one canonical spelling instead, so one value is one string however it was written. The table below says which. For `email`, `url` and `domain`, the standard URL parser decides what they name, not the text. 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.

## The formats

| 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. [Below](#domain-and-hostname).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | Lowercase ASCII, internationalized labels in punycode | `"domain"`                         |
| `hostname()`   | A hostname of ASCII letters, digits and hyphens. [Below](#domain-and-hostname).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | 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. Spaces, hyphens or dots may separate digits, and at most one group may be 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 number is 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. Whether the number 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, so `PT0.5H` and `PT1,5S` are rejected. That is stricter than ISO 8601 and than `Temporal.Duration`, which accept both. Weeks may be combined with other units, as in `P1W2D`, which ISO 8601-2 and `Temporal` allow. 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`. Neither the specification nor `semver()` limits a number or the length. Some libraries refuse a number past `Number.MAX_SAFE_INTEGER` or a version longer than 256 characters: put `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, and a header that names `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. 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. There may be no white space or line breaks, so base64 wrapped at 64 or 76 columns, as PEM and MIME write it, fails. 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: 24 to 32 lowercase ASCII letters and digits, the first a letter. Uppercase is rejected, not lowercased.                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | As written                                            | `"cuid2"`                          |

`isoDate()` produces a string. To get a `Date`, use [`coerceDate()`](docs/text-input.md#dates), which reads the same text as midnight UTC.

### `port`

`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())`.

## `email`

`email(options?, ...checks)` accepts an email address with a public domain name. The value is the address as mail is delivered to it.

The options are `allowPlus`, default `true`, which controls whether `+` is accepted before the `@`, as in `ada+news@example.com`, and the parts described in [Parts of an email or a URL](#parts-of-an-email-or-a-url).

### The local part

The local part, before the `@`, must be a dot-atom, as the email input of HTML accepts:

- ASCII letters, digits and ``!#$%&'*+/=?^_`{|}~-``, with single dots between them. Yes, `{` and `|` are legal there; mail is older than taste.
- A quoted local part, such as `"a b"@example.com`, is rejected.
- So is an address with other letters before the `@` (RFC 6531).
- The local part is limited to 64 characters, and the whole address, as delivered, to 254.

The local part is produced as written.

### The domain

The domain is produced 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`. Every spelling the parser reads as one domain produces the same address, such as fullwidth letters in `ada@ｅxample.com` or an invisible variation selector. 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.

A domain is written with these characters only:

- Letters, marks, digits, dots and hyphens.
- The zero-width non-joiner and joiner, where IDNA allows them, as in `ada@می‌خواهم.com` or `ada@ශ්‍රී.lk`.
- The ideographic, fullwidth and halfwidth full stops (`。`, `．`, `｡`), which count as dots, as `url` reads them too.

A percent escape such as `ex%61mple.com` is URL syntax, which `url` decodes in a host. It is not part of an email domain, so `email` rejects it.

The domain must be a public domain name, as [`domain()`](#domain-and-hostname) requires. These are rejected:

- `localhost`, single-label hosts and IP addresses.
- Special-use names that never reach a public host: 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 `测试`.
- Internationalized labels that are not valid in every browser, so `ada@example.xn--zz` and `ada@٠.com`.
- A domain that ends in a dot, `ada@example.com.`.
- A domain written with more than 759 characters, three for each one a domain can have. It is rejected before the parser reads it.

## `url`

`url(options?)` accepts an absolute URL with an allowed protocol and a public host:

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

`protocols` lists the accepted protocols, without the colon, in any letter case. The default is `["http", "https"]`. The list is copied when the validator is created. 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. The other options are the parts described in [Parts of an email or a URL](#parts-of-an-email-or-a-url).

### What it rejects

- A relative URL: `example.com` and `//example.com` are rejected, and no scheme is guessed.
- Embedded credentials, such as `https://user:password@example.com`, unless a `credentials` validator accepts them, as one for `DATABASE_URL` would. A URL without a host, such as `mailto`, never takes them.
- Port 0, `https://example.com:0/`, which nothing can connect to, unless a `port` validator accepts it.
- Text with white space or control characters, such as a line break. A written URL never contains them, so the text is rejected, not cleaned. Trim first with `pipe(string({ trim: true }), url())` when the input may have surrounding spaces.

### The host

These hosts are rejected, whatever the `host` validator:

- A host longer than 253 characters, the most a domain name can have.
- The host of `http`, `https` or another scheme the URL Standard has rules for, when it is written with more than 759 characters. It is refused before the parser reads it.
- An absolute host such as `example.com.`. It is a second spelling of `example.com`, and would let one host past a list of blocked hosts.
- A host with an internationalized label that is not valid in every browser, such as `xn--zz.com` or `٠.com`.
- A host Node.js cannot read again once it is 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, and no other. 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. `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. It passes in its punycode form, `xn--exmple-4nf.com`. Compare the value, never the text, against a list of hosts.

### The value it produces

The standard URL parser reads the text. Every check is made on what it read: the scheme, the credentials and the host. 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.
- Characters such as `"` and `<` in the path are percent-encoded.

That is URL encoding, not HTML escaping. Other characters, such as `'` and `&`, are kept. Escape the value for the place you write it into, as any text, before you put it in HTML.

### `mailto`, `tel` and `urn`

Three schemes have no host. When `protocols` lists one, a URL of it is checked by its own rules, even when it is written with a host, as in `mailto://example.com`, which names no recipient:

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

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

A `mailto` URL is accepted when:

- It names at least one recipient.
- Every recipient, in the path and in `to`, `cc` or `bcc`, is an address `email()` accepts, on a public domain whatever `host` is. `host` checks the host of a URL, and a mailto has none.
- The query holds 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`. They also allow `/` 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 only in `body`. 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` also refuses every other encoded control character, 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` appear at most once each. `to`, `cc` and `bcc` may repeat, since each adds recipients.

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, and `subject` and `body` as written.

A `tel` URL is accepted when it is a global number: `+`, digits with `-`, `.`, `(` or `)` between them, then optional `;name=value` parameters. A value holds only the characters RFC 3966 allows: letters, digits, `-_.!~*'()[]/:&+$` and `%` escapes, so never `"` or `<`. A local number with `phone-context` is rejected.

A `urn` is accepted when it follows RFC 8141: a namespace of 2 to 32 letters, digits and inner hyphens, a colon, and a name that is not empty.

Any other scheme without a host, such as `file:///etc/passwd` or `about:blank`, is rejected even when it is listed, and whatever `host` is. So listing a protocol never lets a URL without a host 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 gets.

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

## `domain` and `hostname`

`domain()` accepts a public domain name. It is the rule `email` and `url` apply to a host by default:

- Two or more labels, and a last label of 2 to 63 letters, or a punycode label.
- No special-use name, such as `localhost`, `.local`, `.internal`, `.test`, `.arpa`, `.home`, `.corp`, `.mail`, `.localdomain`, the `.svc` and `.cluster` of Kubernetes, or an IDN test top-level domain such as `.テスト`.
- Internationalized labels that are valid in every browser. A punycode label must decode to the label it spells. 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.
- The zero-width non-joiner and joiner where IDNA allows them, as in `می‌خواهم.com`.
- No absolute name such as `example.com.`, and no name written with more than 759 characters, three for each one a name can have. The second is rejected before the parser reads it.

It produces the name in lowercase ASCII, with internationalized labels in punycode.

Whether the top-level domain exists is not checked. So a name under any other label passes, private ones in common use included, such as `nas.lan`, `a.private` or `a.intranet`. Block those with a check of your own when they matter. A public name can also resolve to a private address.

`hostname()` accepts a hostname: labels of ASCII letters, digits and hyphens, separated by dots.

- The last label is not a number: all digits, or `0x` and hex digits, which the URL parser reads as an IPv4 address.
- Punycode (`xn--`) labels must decode to the label they spell and keep the bidi rule, as `domain()` requires.
- Other letters are rejected. So `hostname()` rejects `münchen.de` and accepts its punycode, `xn--mnchen-3ya.de`. To accept both, use `union([domain(), hostname()])`.
- 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.

It produces the name in lowercase. Unlike `domain()`, it accepts `localhost` and private names.

## `ip`

`ip(options?)` accepts an IPv4 or IPv6 address. `version`, `"v4"` or `"v6"`, accepts one address family only. 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.

The addresses it accepts:

- 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, with 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`). After any other address it fails, a scoped multicast one such as `ff02::1%eth0` included.

An IPv4 address has one spelling, and is produced as written. An IPv6 address is produced in its canonical form, so that one address is one string:

- 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`.
- 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: `::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()` writes its address the same way.

## `datetime` and `time`

`datetime(options?)` accepts a date-time such as `2026-09-28T14:30:00Z`:

- It requires the `T` separator, a time, and `Z`, both in uppercase. The lowercase `t` and `z`, and the space for the `T` that RFC 3339 also allows, fail.
- It rejects days that do not exist, so `2026-02-30T00:00:00Z` fails.
- It rejects leap seconds (`23:59:60`).

| Option      | Default | Meaning                                                                                                                                                                                                                                  |
| ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `offset`    | `false` | Also accepts a UTC offset such as `+02:00` in place of `Z`. `+00:00` needs it, as Python's `isoformat()` and Java's `OffsetDateTime` write UTC.                                                                                          |
| `local`     | `false` | 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. Its seconds may be left out, as that input leaves them out. A time with a zone still needs its seconds. |
| `precision` | none    | An integer from 0 to 9: exactly that many digits of a fraction of a second. `0` forbids a fraction. Without it, a fraction is optional. With `local`, it also requires the seconds.                                                      |

A `precision` that is not a number throws a `TypeError` when the validator is created, and one outside that range a `RangeError`.

`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. It converts text, and does not check its form. [Text input](docs/text-input.md#dates) 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. It replaces the format's default rule for that part, and never its syntax:

| Option                 | Receives                                                                                                                                                                                                                                                                                                  | Default rule               |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `url({ credentials })` | `{ username, password }` as the parser writes them, percent-encoded, with `password` empty when only a user is written. `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. The URL produced keeps the spelling of the parser. For other schemes, [see below](#hosts-of-other-schemes). | A public domain name       |
| `url({ port })`        | The port as a number. `undefined` when the URL names none, or names the default of its scheme, which the parser drops.                                                                                                                                                                                    | Any port but 0             |
| `url({ path })`        | The path as the parser writes it, with dot segments resolved and characters such as spaces percent-encoded, starting with `/`. 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, the value of each key a string. 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 issue of the format, at the place of the URL or the address, so a form shows it beside the field the text was typed in. It is `invalid_format`, with `params` `{ format: "url", part: "host", issues }` or `{ format: "email", part: "domain", issues }`. `issues` holds what the part validator found, with paths relative to the part, such as `["page"]` inside the query.

- The `message` of the format 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.
- `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`](docs/text-input.md#a-query-string).
- 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](#mailto-tel-and-urn).

A query key given more than once, as in `?id=1&id=2`, fails unless `repeated` is set. It fails as 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.

### Hosts of other schemes

For a scheme the parser has no rules for, such as `ssh`, `url({ host })` receives 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 the validator as written, while a client may connect to `127.0.0.1`. Check such a host with `hostname()`, `domain()` or `ip()`, which reject those spellings, never with a list of strings.

### Paths a server reads otherwise

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 write a `path` validator as an allowlist, a description of the paths that may pass, such as `string(startsWith("/api/"))`. A denylist such as "not under `/admin`" passes `/;/admin`, `//admin` and `/%61dmin`. An allowlist still holds for each of those readings. It does not hold for a server that decodes the path twice. Such a server reads `/api/%252e%252e/admin` as `/api/../admin`, which is `/admin`, and `startsWith("/api/")` accepts it. For such a server, write an allowlist that leaves out `%`, such as `string(pattern(/^\/api\/[\w/-]*$/))`.

With a `path` validator, two kinds of path fail before any part runs. Each fails as the path part, whose `issues` hold `invalid_value`:

- A path with an encoded `/` or `\`, `%2F` or `%5C` in either case, with `{ encodedSeparator: true }`. The parser, and a check on the path, read `/api/..%2fadmin` as one segment under `/api/`. A server that decodes the separator before routing reads `/admin`, so a prefix check would pass a path it does not cover.
- A path with a segment `.` or `..`, a dot written plain or as `%2E`, followed by `;` or `%3B`, with `{ dotSegment: true }`. The parser reads `/api/..;/admin` as a segment named `..;` under `/api/`. A server that drops the parameters before resolving dot segments, as Tomcat and Jetty do, reads `/admin`. 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. 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 `string(pattern(/^\/api\/[\w/-]*$/))`.

## Next

A format checks a value. Sometimes the value should also change on its way through: trimmed text read as a number, a string turned into a `Date`, a field kept from one shape in another. [Shaping values](docs/shaping-values.md) covers those.

<!-- Source: docs/shaping-values.md -->

# Shaping values

A validator does more than say yes or no. It can clean a value, convert it into another type, and later write it back. This page covers the tools that change what a validator produces, or how it is described, and the ways to build one object from another. In the sign-up, they read the time an invitation expires as a `Date`, mark a user's id so that no other string passes for one, and derive an account update from the shape that creates the account.

## `pipe`

`pipe(a, b, ...)` runs validators in order. Each is given the value the previous one produced, and `pipe` produces what the last produces:

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

const address = pipe(string({ trim: true, max: 254 }), email());
const age = pipe(string({ trim: true }), coerceNumber({ int: true, min: 13 }));
```

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.

Its input type is the input type of its first validator.

## `transform`

`transform(validator, convert)` changes the value a validator produced into another, such as text into its length:

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

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

length("Ada"); // { ok: true, value: 3 }
```

- `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 conversion that can fail is a validator of its own, as [Custom validators](docs/custom-validators.md#a-conversion-that-can-fail) shows.
- A `convert` that throws is a bug, and the exception propagates.
- A `transform` goes one way. [`encode`](#encode) cannot write its value back, and throws, naming where it is. Where a value must go back, use a [`codec`](#codec).

A `convert` that returns a promise makes the result asynchronous. So does one typed as possibly returning one, including one typed as returning `unknown`. So does any value with a `then` method, since `await` would treat it as a promise, whether it meant to be one or not. Wrap such a value in an object before it is returned.

A `convert` 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.

## `codec`

`codec(input, output, { decode, encode })` reads a value in the form it is sent, and returns 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:

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

const expiresAt = 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, and `Conversions<typeof input, typeof output>` is the type of the pair.
- A function that throws is a bug and propagates, as the function of a `transform` 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 written as a JSON Schema as its `input`, or as its `output` with `io: "output"`. An output JSON Schema cannot express, such as the `Date` of `expiresAt`, throws there unless `unrepresentable: "any"` is given, as [JSON Schema](docs/json-schema.md) says.

## `encode`

`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 expiresAt = codec(datetime(), date(), {
  decode: (text) => new Date(text),
  encode: (value) => value.toISOString(),
});
const invitation = object({ code: string(), expiresAt });

encode(invitation, { code: "AB12", expiresAt: new Date(0) });
// { ok: true, value: { code: "AB12", expiresAt: "1970-01-01T00:00:00.000Z" } }
```

`Encoded<typeof validator>` is the type of what it returns: a result, to be awaited when the validator is asynchronous. Each kind of part is written back its own way:

- **A `codec`** writes the value back through its `output` first. A codec inside the output writes its part first. An output written by hand checks the value and passes it on unchanged. Then the codec runs its `encode` on the result, and checks that its `input` accepts what that returns.
- **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 validator it came from then reads the text back, 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. 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. 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` that names its place, as in `TypeError: encode cannot write back a transform at the root. Use a codec there`. That is a `transform`, whose function goes one way, a validator written by hand, and a `codec` with a `transform` in its output. `formData` is refused too.

So a value the validator could not have produced fails with the issues it has, at their paths: `encode(expiresAt, 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, and throws a `TypeError` that names its place. Use a codec that writes it as text, as `amount` above does.
- On each call, it builds a validator of each part of the schema the other way, from the same factories. So a program that calls it bundles every composer.

## `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, a session token, or the text of a 404 page. 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. `pick` and its kin 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. `ReadonlyOutput<T>` is the type it gives.

```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 only their type. `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`, which is JavaScript's idea of frozen. A typed array cannot be frozen, and is left unfrozen, and so is a `DataView`. The type covers properties and not methods, so a `Date` keeps its setters.
- A value counts as the caller's only 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. The same goes for an object it returns from your own data: `readonly(transform(string(), (key) => cache[key]))` freezes the entry in `cache`, so return a copy there too.
- `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, to whoever reads the schema: a person reading the documentation of an API, 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 the name JSON Schema has for it. `Meta` is the type of the second argument.

```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. Its description is that of the one given, 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` counts as not given.
- `title` and `description` are text. `examples` is a list of JSON values: `null`, booleans, finite numbers, text, and lists and plain objects of them. `deprecated` is `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:

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

const theme = fallback(oneOf(["light", "dark"]), "light");

theme("purple"); // { ok: true, value: "light" }
```

This turns bad input into a value that looks valid. Keep it for data where a sensible default is safer than an error, such as a stored preference that may be out of date.

- The fallback is trusted, and is not validated.
- A function is called with the issues that were found, which is the place to log them. So a fallback that is itself a function is returned from one: `fallback(validator, () => callback)`. The types reject the callback itself whenever the validator inside 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 primitive is used directly. An array or an object must come from a function, such as `fallback(array(string()), () => [])`. 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 it is created from JavaScript.
- 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 be reported, keep `fallback` off a recursive schema.

Its input type is `unknown`, since it accepts anything.

## Reusing shapes

### Spreading a shape

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:

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

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

const createAccount = object(account);
const updateAccount = object(partial(account)); // every property optional
const withAge = object({ ...account, age: number({ int: true, min: 13 }) });
```

- 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. `object`, `partial` and `tagged` throw a `TypeError` when they are created with a symbol key or a key that is not enumerable, since its validator would never run.

`PartialShape<TShape>` is the type `partial(shape)` returns.

### `pick`, `omit`, `required`, `partial` and `extend`

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

```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. A property made with `optional` becomes the validator it wrapped, so its default is removed too, and a missing property fails. One made with `nullish` becomes `nullable`, which still accepts `null`. A property that accepts `undefined` some other way, such as `unknown()`, is kept unchanged.

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

- Each option given replaces the one of its name, and leaves the others. So a strict object picked from with only a `message` stays strict.
- An option given as `undefined` counts as not given, and keeps the one of the original. So `pick(strictUser, ["name"], { unknownKeys: undefined })` stays strict.
- A validator given as `unknownKeys` is kept too. So a property that `pick` or `omit` leaves out goes to it when the input has it, as any other key the new shape does not list does.

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. 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, since that is how they know its shape. `objectLike`, a `pipe` or a `transform` around an object, and a validator written by hand are a `TypeError`. They read the shape through [`describe`](docs/json-schema.md#reading-a-schema), which is how you would write one of your own.

The types they return have names, for a library that exports what it derives:

- `Picked<T, K>` and `Omitted<T, K>`: the object type with only the named properties, or without them.
- `AllOptional<T>` and `AllRequired<T>`: the object type with every property optional, or every property present and none of them `undefined`. A property typed `unknown` or `any` still admits `undefined`, as `required` keeps an `unknown()` property unchanged.
- `Extended<T, TShape>`: the object type `T` with the properties of `TShape` added or replaced.
- `Reshaped<TValidator, T, TInput?>`: a validator of `T` that is synchronous when the validator it was made from is.

## Next

A validator made from these tools is still one validator, so the libraries that take a schema take it directly. [Standard Schema](docs/standard-schema.md) covers how.

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

# Standard Schema

The sign-up form runs in a form library, and the sign-up endpoint in a web framework. Neither should need an adapter for each validation library, and there are a lot of validation libraries. We are aware we made one more. [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. This page covers how a validator of this package is one, and how to choose the wording of its messages.

Every validator a factory of this package makes is already a Standard Schema:

```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. The `~standard` of a validator 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 bundles it.

Some issues get less than a sentence:

- A check named by an identifier in code, such as `startsWith`, and a format whose name is written that way, such as `creditCard`, are worded "Invalid value". The short English does not show a reader the name of a function.
- An unrecognized key is "Unrecognized key", since the key is the last segment of the path.

`standard` words both in full.

`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`, `spanishMessages` 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/forms.md#the-types-of-a-form), 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 too, and types what it hands you after validation as a number.

The package exports the `StandardSchemaV1` type so you can accept one in your own code, and `StandardJSONSchemaV1` for one that also carries its JSON Schema.

## 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, even when it passes another map, since it is the default. 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" }`. That is the one library option, and any other name in `libraryOptions`, such as a misspelled one, throws a `TypeError` rather than being ignored. Give [`meta`](docs/shaping-values.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 in JavaScript a function is one, whatever `typeof` says. 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"] }`.

## Next

Some libraries read a schema rather than call it: an API description, a form generator, or a language model choosing the arguments of a tool. [JSON Schema](docs/json-schema.md) covers how to write a validator as one.

<!-- 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. The sign-up endpoint can publish its body in an OpenAPI document from the same validator that checks it.

This page covers what the schema promises, how each validator is written, and how to read a validator yourself with `describe`. The schema it returns has the type `JsonSchema`, and its options the type `JsonSchemaOptions`.

```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" },
//     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 opinion on the matter. 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. In two, the validator cleans a value before it checks it, which a schema cannot say, and in the other, the two count the length of text differently:

- **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 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. A `pattern` is the same: a schema reads it in Unicode mode, the `u` flag, so in one written without that flag `.` and a negated class such as `[^a]` match a whole character in the schema and half of an emoji in the validator.
- **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.

## 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: { anyOf: [{ type: "integer" }, { type: "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                      | An `anyOf` of the schema of the type they produce, bounds and checks included, and 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`, as the table below writes it                                                                   |
| `port`, `base64`                                            | An integer from 1 to 65535, and a string with `contentEncoding`                                                                |
| `object`, `objectLike`                                      | `type: "object"` with `properties`, `required`, and `additionalProperties` when strict or given a validator for unknown keys   |
| `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`                                                                            |

`multipleOf` is written only for a step binary floating point holds exactly: an integer it holds as written, so not `1e23`, or a fraction such as `0.5` or `0.25`. A reader divides in binary, where `0.3 / 0.1` is not `3`, so with a step such as `0.1` or `0.01` it would refuse values the validator accepts; such a step is left out. Ajv also refuses some very large numbers: it divides the value by the step and reads the result with `parseInt`, which misreads a result of `1e21` or more, so it refuses `1e25` against `multipleOf: 5`, which `number(multipleOf(5))` accepts. Where Ajv reads the schema, give the number a `min` and a `max` that keep the value divided by the step strictly between `-1e21` and `1e21`. Above `2 ** 53`, where a number is not the integer its digits spell, any reader that divides can refuse a value the validator accepts, as it does `20054952883093950` with `multipleOf(5)`. `uniqueItems` is written only for items that are primitives, since `unique()` compares an object by identity and `uniqueItems` by what it holds.

A format is written under JSON Schema's name for it, and with no `format` where that name's rules refuse text the validator accepts, which would break the promise above:

| Format                                                   | Input                                                   | Output     |
| -------------------------------------------------------- | ------------------------------------------------------- | ---------- |
| `email`                                                  | None, since the domain may be internationalized         | `email`    |
| `domain`                                                 | None, for the same reason                               | `hostname` |
| `hostname`, `uuid`                                       | Their own names                                         | The same   |
| `datetime`                                               | `date-time`, and none with `local`, which has no offset | The same   |
| `isoDate`                                                | `date`                                                  | The same   |
| `ip`                                                     | `ipv4` with `version: "v4"`, and none otherwise         | The same   |
| `time`, `duration`, `url`                                | None                                                    | None       |
| `port`, `base64`                                         | As the table above writes them                          | The same   |
| Any other, and any made with `format`, whatever its name | None                                                    | None       |

A format is left out where JSON Schema's has stricter rules: `time` needs an offset, which a time of day such as `12:00` never has, `duration` allows no fraction, as in `PT0.5S`, and no weeks beside days, as in `P1W1D`, an IPv6 address may have a zone, as in `fe80::1%eth0`, which `ipv6` does not allow, and a URL may hold `|` or `^`, which a URI may not. The input of `email` and `domain` may be internationalized, as in `user@bücher.example`, which `email` and `hostname` refuse; `idn-email` and `idn-hostname` would fit, but [ajv-formats](https://github.com/ajv-validator/ajv-formats) does not know them, and Ajv in its default strict mode refuses to compile a schema with a format it does not know. That is also why a format JSON Schema has no name for, such as `phone`, `slug` or one made with `format`, is written with none.

An `object` writes `additionalProperties: false` when strict, and the schema of the validator it gives its unknown keys when it has one, as a `record` writes the schema of its values. A passthrough or stripping object writes none, since any other key passes. The schema says nothing of `__proto__`, which a passthrough object, one with a validator of its unknown keys and a `record` reject, so a schema validator accepts it there.

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 [Limits](docs/limits.md#build-a-recursive-schema-once) explains. 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/shaping-values.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`, and so is an option `toJsonSchema` does not have, such as `draft` written for `target`, as an unknown option is for a factory. [`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`, `map` and `file`, none of which is a JSON value, `formData`, which reads a form and not JSON, 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` or `v`, which JSON Schema patterns do not have. The flags `g` and `y`, which `pattern` drops, are left out, and so is `u`, the mode a schema reads every pattern in.
- A `pattern` written without `u` that Unicode mode reads otherwise: one that is an error there, such as `/^\@x$/`, whose `\@` only that mode refuses, and one with `\u{…}` or `\p{…}`, which are escapes only there and text with braces without it.
- What a `transform` returns, on the output side.
- A check on an `object`, and a variant of `tagged` that is not an `object`, such as a `lazy`. With `unrepresentable: "any"` that variant is written as an object with only its tag, so the other variants still match one branch of the `oneOf`.

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.

## Reading a schema

`toJsonSchema` writes one notation, and there is always another. To write it, or to build a form from a schema, read the validator yourself. `describe(validator)` returns what a validator is made of, as plain data, typed `Description`: 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 signupForm = object({ name: string({ min: 2 }), email: email(), nickname: optional(string()) });

const top = describe(signupForm);
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"`, `"file"` | 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"`, `"formData"`                                                                | `json`, `searchParams`, `formData`                | `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.

## Next

The libraries that take a Standard Schema or a JSON Schema each have a few details of their own, which is the polite word for quirks. [Integrations](docs/integrations.md) shows the common ones, with the sign-up.

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

# Integrations

The sign-up runs inside other libraries: a form library in the browser, a framework on the server and, this being the 2020s, perhaps the tools of a language model. This page shows a validator in each of the common ones, so you can copy the few lines each needs.

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), in which the tools of language models and OpenAPI documents are written.
- Called directly, as any function is. That is also how one field of a [zod or valibot schema](#inside-a-zod-or-valibot-schema) can use it.

Every validator a factory of this package makes is already a Standard Schema, worded in short English. The examples for forms, RPC and HTTP pass it through `standard`, which words its issues in the full English, and were run that way. The other examples use what their section names: `standardJsonSchema`, `toJsonSchema`, or the validator called directly. Each example 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. Libraries move, so the date says when these were last true.

## 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/forms.md#the-types-of-a-form). A field of `coerceNumber()` holds text or a number. What the form submits depends on the library: react-hook-form submits what the validator produced, typed with `Infer`, and TanStack Form submits the values as typed.

### 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 }`. `onSubmit` is given the values as typed, not what the validator produced, so call the validator on them to get, for example, the number of a `coerceNumber()` field.

## 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/shaping-values.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` unchanged. A schema with a `lazy`, recursive or not, is the exception. It refers to its definitions as `#/$defs/schema1`, from the root of the document it is in, and the root of an OpenAPI document has no `$defs`. So such a schema needs its definitions moved under `components.schemas`, and its references rewritten to point there. 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, so nobody has to book a migration weekend. 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.

## Next

A validator in production meets input written to hurt it. [Limits](docs/limits.md) says what the package bounds for you, and what it leaves to you.

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

# Limits

A sign-up endpoint receives whatever anybody sends it, including input written to make it slow, to exhaust its memory, or to crash it. This page says what the package bounds for you, what it leaves to you, and how to check that a schema sets the bounds it needs. Read it before you validate input from a public network: it is shorter than the incident report.

In short:

- A failing input reports a bounded number of issues: each collection stops at 1,000, and a recursive schema stops at a depth and a number of calls.
- An input that passes is validated in full. So its size is yours to cap, with a `max` on text and on every collection.
- `audit` lists every place a schema leaves a size open, so a test can keep it closed.

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

// A URL whose host is written as an address a server must not fetch fails. The host is not looked up,
// so a public name that resolves to such an address passes: check the address you connect to as well.
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"
```

## A failing input has a bounded cost

A collection stops once its items have reported 1,000 issues. The items after that are not validated. One more issue, `too_big` with `{ maximum: 1000, type: "issues" }` at the path of the collection, says that it stopped. The limit is for each collection, not for the whole input, so an `object` with two lists can report about 2,000.

- 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. A key that fails counts with the issue behind it.
- The unknown keys of a strict `object`, or of one that gives them to a validator, 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`.

A wrong size is reported at once, without validating the items. So the items of a huge input that breaks a `max` are never validated only to be rejected, though a `record` still lists the input's keys to count them.

## Recursive data

A recursive schema uses `lazy`, as [Request bodies](docs/request-bodies.md#recursive-data) shows. Recursion is where a small input can cost the most, so `lazy` has two limits:

| Option     | Default | Counts                                           | Past it                                     |
| ---------- | ------- | ------------------------------------------------ | ------------------------------------------- |
| `maxDepth` | 128     | The levels of `lazy` open at once, of any `lazy` | `too_big` with `{ maximum, type: "depth" }` |
| `maxCalls` | 10,000  | The calls one `lazy` makes in one validation     | `too_big` with `{ maximum, type: "calls" }` |

Each must be a positive integer, or `lazy` throws when it is created.

### `maxDepth`

Each level of nesting is one level of recursion, and the JavaScript stack holds only so many. So `lazy` counts them, and a value found deeper than `maxDepth` fails at its own path:

- 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 shared by every `lazy`, not kept for each one. That is what bounds the stack when two recursive validators call each other. 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 the share of one validator.
- A level opened before an `await` is still open after it. So an asynchronous recursive schema is held to `maxDepth` too, given a cyclic object as well.

Raise `maxDepth` only for data you know is deeper. For a schema that does not wait at every level, raise it only as far as the stack of your runtime holds for the validators you wrote. How much a stack holds depends on the runtime. A worker has about half the stack of a page in Chromium and Firefox. A schema with many validators at each level needs more of it, so lower `maxDepth` for such a schema where it runs in a worker.

### `maxCalls`

Work can 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 result of the children, 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. Its work doubles with each level, so a small valid input can take a very long time. `maxCalls` stops that: past it, every further call of that `lazy` fails.

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

What counts as one validation:

- 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 the count of each `lazy`, and validations made one after another, such as one per request, have counts of their own.
- Each `lazy` counts its own calls against its own limit. So a limit set on one is never overridden by another the validation reaches.
- A validation made from a callback you wrote is counted apart from the validation that ran the callback: the test of a `check`, the function of a `transform`, a `guard`, the test of a `format`, the default of `optional` or `fallback`, or the getter of a `lazy`. A validator given as a child, even one written by hand, is part of the validation that reaches it.
- Like `maxDepth`, it lasts across the awaits of this package, such as those of an asynchronous `check`. 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 and levels, so neither limit bounds recursion through it. Put asynchronous work in a `check`, or bound such a validator yourself.

What counts as one call:

- Every object, array or other value with parts that reaches the `lazy`, 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.
- Not a primitive that reaches the `lazy` and ends the recursion there, such as a number in a list. So a list of a million numbers is held to no limit but its `max`.
- A primitive the recursion goes on through, such as text a `json` or a `transform` reads the next level from, and one whose result is pending, as under an asynchronous `check`.

A validation stopped by the limit keeps the issues found on the way, each with a path no deeper than `maxDepth` allows. Set the limit on every `lazy` of the schema, lower for small untrusted input, and raise it for recursive data with more nodes than that in one validation.

A `lazy` under a `union` fails as that option. So the code the caller sees is the `invalid_union` of the union, with the `too_big` issue inside `params.issues`. `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. For objects told apart by a property, use [`tagged`](docs/request-bodies.md#tagged), which reads the property first and validates only the matching variant.

### Build a recursive schema once

A `lazy` made while the getter of another `lazy` runs shares the count of that one, 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 that builds 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. `audit` and `toJsonSchema` throw a `TypeError` for it. 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;
};
```

## Sizes are yours to cap

The limits above are about the stack, and the work for each node. They are 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, and those of a recursive value are bounded by `maxDepth` and `maxCalls`. But 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 text before you parse it, as in `pipe(string({ max: 100_000 }), json(category))`.
- Give every `array`, `set`, `map` and `record` a `max`.
- Give every `file` a `maxSize`, and cap the size of a request body where the request is read.

A value received by structured clone, such as `event.data` from `postMessage`, needs the `max` whatever its size in bytes. It can hold a sparse array, or one array at many places, so a message of a few bytes can hold millions of items.

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

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

const signupBody = object({ name: string({ max: 100 }), email: email(), interests: array(string({ max: 50 })) });

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

Give `interests` a `max`, and the list is empty, which a test asserts:

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

const signupBody = object({ name: string({ max: 100 }), email: email(), interests: array(string({ max: 50 }), { max: 10 }) });

audit(signupBody); // []
```

Each finding is an `AuditFinding`, with 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, `json` or `searchParams` given open text, and a `coerceBigint` without a bound on each side |
| `"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                                                                                                              |

How it reads a schema:

- **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. The `max` of a collection bounds how many items it has, not what each holds, so the items after it still need their own bounds, unless the items before were bounded too or are numbers, booleans, dates or literals, which hold nothing more.
- **Text read as a bigint** takes longer than linear time to convert. So `coerceBigint` needs `min` or `gt`, and `max` or `lt`. They limit the digits it converts, and text with more is refused without being converted.
- **A path** joins property names with `.`. It 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 unknown keys an `object` gives a validator, 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. One built anew at every level, as `lazy(() => build())` builds it, never repeats, so `audit` throws a `TypeError` for it, as `toJsonSchema` does.
- **`formData`** is read through, to what it holds.

What it does not judge:

- Whether a `max` is small enough. Any number says someone decided.
- Checks, since they run on a value that already passed, and anything a `guard` or a validator written by hand does inside.
- What no option bounds. How many keys an `object` with `unknownKeys: "passthrough"` or a validator keeps, and what `unknown()` accepts, are not reported. The validator itself is read, so its own bounds are. Use `"strip"` or `"strict"` for keys the schema does not name, and a validator of the type you expect in place of `unknown()`.

## Next

That is the end of the reading path, and of our opinions about `__proto__`. For the exact signature of every export, see the [API reference](docs/reference/index.md). For what changed between versions, and how to upgrade, see the [changelog](docs/changelog/index.md).

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

# 0.5.1

0.5.1 changes no signature, issue code or `params` shape, so upgrading from 0.5.0 needs no action. It adds a validator for the keys an object does not list and messages in Spanish, makes valid input to an object faster in three common cases, and fixes what the review after 0.5.0 found.

## Added

- `object(shape, { unknownKeys: validator })` validates each key the shape does not list with that validator and keeps what it produces, as zod's `catchall` and valibot's `objectWithRest` do: `object({ name: string() }, { unknownKeys: boolean() })` accepts `{ name: "Ada", beta: true }` and refuses `beta: "yes"` with `invalid_type` at `["beta"]`. Its issues follow those of the listed properties and stop at the 1,000 a collection reports. An unlisted `__proto__` is refused as `record` refuses it, and an asynchronous validator makes the object asynchronous. The type gains an index signature for the other keys. `pick`, `omit`, `partial`, `required` and `extend` keep it and accept one in their options, `toJsonSchema` writes it as `additionalProperties`, `audit` walks it and `encode` writes it back. [Getting started](docs/getting-started.md#keys-the-shape-does-not-list) covers it.
- `spanishMessages`, the built-in wording in Spanish beside `englishMessages` and `portugueseMessages`: "Debe ser como mínimo 18". It writes a bound or a step with a decimal comma, ends a long list with "y 2 más", and words every message about a key as "la clave". It is a separate export, so only a program that imports it bundles it. [Issues and messages](docs/errors.md) says how to write a decimal point instead, as in Mexico.

## Changed

- Valid input to an `object` runs less of the full work in three cases. An object with a `check` asks its properties' fast tests first and runs the check once, from about 1.1 to 1.8 million validations a second for three strings and a check. A property with built-in checks, such as `string(startsWith("x"))`, keeps its fast test, from 1.2 to 3.4 million a second. An object with a `transform` property asks its other properties first and runs each one that has no fast test exactly once, from about 1.6 to 2.2 million a second. A `lazy` tree also builds its record of paths only for an object it reaches a second time. All measured with `pnpm bench` on Node.js 24.22.1; a `transform` or a check you wrote still runs once per validation, as before.
- `size.json` records each export imported alone. The new `unknownKeys` path and the faster runs cost a lone `object()` 3.61 kB gzipped, from 3.35 kB in 0.5.0, and a lone `boolean()` 2.07 kB, from 2.01 kB.

## Fixed

- `base64()`, `base64url`, `jwt()` and `creditCard()` no longer throw `RangeError: Maximum call stack size exceeded` on text of a few megabytes, which `base64()` did from 5,000,000 characters and `creditCard()` from about 6,700,000. Each refuses or accepts what it did before; `creditCard()` refuses text longer than 37 characters without reading it, since such text holds more than 19 digits.
- `transform` is typed asynchronous when its `convert` returns any value with a callable `then`, where it was typed synchronous unless the value was typed as `PromiseLike`, while `await` made the result a promise at run time. A program this changes was already given a promise where its type promised a result.
- An exclusive date bound reported by a custom validator, with `inclusive: false`, is worded without "on or" in every message map, which said the bound itself was allowed.

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

# 0.5.0

0.5.0 adds no export. It makes what the package already did hold where it did not: the JSON Schema it writes compiles in Ajv and accepts what the validator accepts, except for the cases [JSON Schema](docs/json-schema.md#what-the-schema-promises) lists and the very large numbers Ajv misreads under [`multipleOf`](docs/json-schema.md#how-each-validator-is-written), `encode` writes a codec back through its output, `audit` finds bounds it missed, and recursion that waits between levels stops at `maxDepth`. Nine changes are breaking, each marked below with what to do. Six change what `toJsonSchema` writes or when it throws, and how `pick` and its kin, `encode` and asynchronous recursion behave. The other three refuse input 0.4.1 accepted, or throw where it returned: a `__proto__` key an `intersection` copied, text of invisible characters in `nonBlank`, and a validator written by hand that returns a failure with no issues. The README and the guides are now also written in Brazilian Portuguese and Spanish.

## Added

- The README and every guide are available in Brazilian Portuguese and Spanish, as `README.pt-BR.md` and `README.es.md` and under `docs/pt-BR/` and `docs/es/`. The English pages stay the source, and the API reference and this changelog are in English only.

## Changed

- **Breaking.** `pick`, `omit`, `partial`, `required` and `extend` merge the options they are given into those of the object they come from, where 0.4.1 replaced all of them when given any. So `pick(strictUser, ["name"], { message: "Invalid user" })` stays strict, where it quietly turned to stripping unknown keys. An option given as `undefined` counts as not given, so `{ unknownKeys: config.mode }` with no mode set keeps the original's. A name the object does not know still throws, as `object` does, even given as `undefined`: `{ unknownkeys: config.mode }` is a `TypeError`. To get the old result, name the option you want: `pick(strictUser, ["name"], { unknownKeys: "strip", message: "Invalid user" })`. [Shaping values](docs/shaping-values.md#pick-omit-required-partial-and-extend) covers it.
- **Breaking.** `encode` writes a codec's value back through its `output` first, then gives the result to the codec's `encode` function, where 0.4.1 checked the value with `output` read forward. So a codec inside the output writes its part first: for `codec(string(), object({ at: expiresAt }), …)`, where `expiresAt` is a codec of text to a `Date`, the outer `encode` is given `{ at: "2026-10-07T12:00:00.000Z" }` where it was given the `Date`. `Conversions["encode"]` is typed to receive `InferInput<typeof output>`, so an `encode` function typed for the decoded value fails to type check: type its argument as the output's input type. A `transform` in a codec's output now throws the `TypeError` that names its place, where 0.4.1 ran it forward: `codec(string(), transform(number(), (n) => n * 2), …)` encoded `42` as `"84"`, which decodes to `168`. Use a codec there. An output written by hand still only checks the value. [Shaping values](docs/shaping-values.md#encode) covers it.
- **Breaking.** A recursive schema that waits at each level, such as one with an asynchronous `check` on every node, is held to `maxDepth`, 128 by default, as a synchronous one is. 0.4.1 counted levels only until the first `await`, so such a schema had no limit but `maxCalls`, and a body of 10,000 nested levels, each with a field that fails, under 200 kB, kept 402 MB in memory. Valid data nested deeper now fails with `too_big` and `{ type: "depth" }`. If yours is that deep, raise `maxDepth` on the `lazy`, as [Limits](docs/limits.md#maxdepth) explains.
- **Breaking.** `toJsonSchema` writes a `format` only where JSON Schema names it and its rules accept what the validator accepts. So Ajv with ajv-formats compiles every schema it writes, `Ajv2020` for the default draft 2020-12 and `new Ajv()` for `target: "draft-07"`, which it refused for one holding a format such as `phone` or `slug`, and a reader accepts what the validator accepts, which it did not for `user@bücher.de` under `email()`. It writes none for the input of `email` and `domain`, which may be internationalized, for `time`, `duration`, `url`, an `ip` other than `version: "v4"` and a `datetime` with `local`, for the formats JSON Schema has no name for, such as `phone`, `slug`, `ulid` or `creditCard`, and for any format made with `format`, even one named `"uuid"`. The output of `email` and `domain` keeps `email` and `hostname`. A reader that registered a name such as `phone` no longer checks it from the schema; add the keyword to the schema you hand it. [JSON Schema](docs/json-schema.md#how-each-validator-is-written) lists what each format is written as.
- **Breaking.** `toJsonSchema` writes the input of a coercion as an `anyOf` of the type it produces, with its bounds, and the types it converts from: `coerceNumber({ min: 5, max: 10 })` is `{ anyOf: [{ type: "number", minimum: 5, maximum: 10 }, { type: "string" }] }`, where it was `{ type: ["number", "string"] }`. A reader now refuses `20` there, as the validator does. Code that reads the `type` of such a schema reads the `anyOf`.
- **Breaking.** `toJsonSchema`, and the `libraryOptions` of `standardJsonSchema`, throw a `TypeError` for an option they do not know, such as `{ draft: "draft-07" }` where `target` was meant, which 0.4.1 ignored; `null` counts as no options. They also refuse a `pattern` with the `v` flag, and one written without `u` that Unicode mode reads otherwise, such as `/^\p{L}$/`, which without `u` matches the text `p{L}`, since a schema reads every pattern in Unicode mode. Remove the unknown option; write such a pattern with `u`, or pass `unrepresentable: "any"` to leave it out. [What cannot be written](docs/json-schema.md#what-cannot-be-written) lists each case.
- **Breaking.** `intersection` reports an own `__proto__` key that one side passed on unchecked, as `unknown()` does, as `invalid_key` with `{ reserved: true }` inside, as `record` does since 0.4.0. 0.4.1 copied it into the object it built, so `Object.assign({}, output)` of the validated `{"__proto__":{"admin":true}}` had `admin`. A shape of either side that lists `__proto__` at that place keeps it, when that shape made the value there: one before a `transform`, or in a step of a `pipe` before a step that makes a new object, does not, and neither does the key of a `map` entry for its value. A value both sides pass on whole is kept unchanged. [Request bodies](docs/request-bodies.md#intersection) covers it.
- **Breaking.** `nonBlank()` counts invisible characters as blank: the format characters such as the zero-width space U+200B and the soft hyphen, the Hangul fillers and the blank braille pattern U+2800. Text of only those looks empty and now fails, where 0.4.1 required only a character that is not white space. Data that passed before may fail when validated again.
- **Breaking.** A validator written by hand that returns a failure with no issues, `{ ok: false, error: { issues: [] } }`, is now no result, and every composer throws the `TypeError` naming where it is, where `object`, `tuple` and `array` passed, leaving out the value it refused. A passing result is still read by its `ok` alone, so its `error` is never read. `is()` reads a result as `assert` does, so it throws for that failure, and for a validator that returns no result, where it returned `false`. `fallback` throws for a validator that returns no result, such as `string` given uncalled, where it returned the fallback value for every input, and the key validators of `record` and `map` throw the same `TypeError`, where they threw one from reading `undefined`. Return at least one issue from a failure, for example with `fail`. [Custom validators](docs/custom-validators.md#a-validator-that-returns-no-result) covers it.
- The short English of a validator's own `~standard`, which a form library shows, words a check and a format whose name is camelCase as "Invalid value", where it said "Invalid startsWith" or "Invalid creditCard", and so a format named with a digit before a capital, such as `sha256Hash`; a one-word format keeps "Invalid email". It says "Unrecognized key" without the key, which the path holds, writes a date bound in UTC, so the text is the same on every machine, and words a step as "Must be a multiple of 5". `standard(validator)` still gives the full English.
- `portugueseMessages` writes a bound or a step with a decimal comma, "Deve ser no mínimo 0,1", ends a long list with "e mais 2" where it said "ou mais 2", and words every message about a key in the feminine, "Deve ser informada apenas uma vez".
- Every validator carries the wording above and the stricter reading of a result, so a lone `boolean()` is 2.01 kB gzipped, from 1.86 kB in 0.4.1. `size.json` in the package records each export imported alone.
- The guides were reorganized into pages along one reading path, which [the overview](docs/index.md#reading-path) lists, and `validators.md` and `coercion.md` were replaced. What they held is now in [Getting started](docs/getting-started.md) (strings, numbers, other primitives, objects, checks, `Infer` and `is`), [Formats](docs/formats.md), [Request bodies](docs/request-bodies.md) (collections, `union`, `tagged`, `intersection`, `lazy`, `json`), [Forms](docs/forms.md) (optional fields, defaults, files, `InferInput`), [Shaping values](docs/shaping-values.md) (`pipe`, `transform`, `codec`, `encode`, `brand`, `readonly`, `meta`, `fallback`, `pick` and its kin), [Text input](docs/text-input.md) (the coercing validators, `searchParams` and the environment), [Limits](docs/limits.md) (the limits of `lazy` and `audit`), [Issues and messages](docs/errors.md) (`formatIssue`, `flatten`, `assert` and the wording of one validator), [Custom validators](docs/custom-validators.md) (`pass`, `fail` and the exported types), [Standard Schema](docs/standard-schema.md) (handing a validator to other libraries) and [JSON Schema](docs/json-schema.md#reading-a-schema) (`describe`). The README keeps one example, and `llms.txt` links every page with a line on what it covers.

## Fixed

- `coerceBigint` refuses text with more digits than its bounds allow before converting it, with the issue of the bound it passes, since converting takes longer than linear time: 8,000,000 digits took 0.8 seconds in one measurement, and are refused in 4 ms.
- `date()` and `coerceDate()` no longer ask a plain object whether it is a `Date` by catching a throw: in one measurement, 100,000 `{}` under a `union` with `date()` took 2 seconds, and take 40 ms.
- A value quoted in a message, such as an unrecognized key, escapes U+2028, U+2029 and the C1 controls, so text the sender chose cannot start a new line in a log.
- `domain()` and `email()` accept a zero-width non-joiner or joiner where IDNA allows one, as in `می‌خواهم.com` or `ශ්‍රී.lk`, which `url()` and the browsers accept. One between Latin letters, such as U+200C in `a‌b.com`, and its punycode `xn--ab-j1t.com`, are still refused.
- `domain()`, `email()`, `url()` and the punycode labels of `hostname()` refuse a right-to-left name holding a character the bidi rule of RFC 5893 does not allow there, such as `¡` or `°` at the end of a label, which 0.4.1 guessed from its Unicode block.
- `url()` given a `host` validator refuses a host written with a Hangul filler, U+115F, U+1160, U+3164 or U+FFA0, as it is or percent-encoded. Node.js, Chromium and Firefox drop the filler and WebKit refuses the host, so 0.4.1 accepted `https://𞤀ㅤ/` as `https://xn--9d6h/`, a URL runtimes read differently.
- A `union` whose options reach the same object through a different number of `lazy` levels no longer gives one option the `maxDepth` failure another met deeper: with `node` a `lazy` of `maxDepth: 2`, `union([lazy(() => lazy(() => node)), node])` refused an object `node` accepts.
- `audit` reports what it missed: a `coerceBigint` without a bound on each side, as `unbounded_text`; the items after a collection's `max` in a `pipe`, which that `max` bounds only in count, unless the items before it are of a fixed size, such as numbers, booleans, dates and literals; and a format made with `format` under a built-in name, such as `format("email", test)`, which bounds no text. It throws a `TypeError` for a recursive schema built anew at every level, where it overflowed the stack. A test that asserts `audit` finds nothing may now fail; give the part the bound it names. [Limits](docs/limits.md#checking-a-schema-for-the-bounds-it-needs) covers it.
- `toJsonSchema` writes `multipleOf` only for a step binary floating point holds as written, an integer such as `5` or a fraction such as `0.25`, since a reader refused `0.3` against `multipleOf: 0.1`, and `3e23` against `multipleOf: 1e23`, which binary holds as 99999999999999991611392. It writes `uniqueItems` only over primitive items, since `unique()` compares an object by identity. It writes a hyphen in a pattern without `\`, which Unicode mode refuses; writes a `tagged` variant it cannot write, with `unrepresentable: "any"`, as an object holding its tag, so the other variants still match one branch; and writes a long chain of distinct `lazy` parts built once, which it refused past 64 as a schema built anew. [JSON Schema](docs/json-schema.md#how-each-validator-is-written) also says where Ajv refuses a large multiple.
- `encode` throws a `TypeError` naming the place of a `bigint` inside `json`, which JSON cannot hold.

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

# 0.4.1

## Changed

- Nothing a consumer imports has changed: the code is the same as 0.4.0's, so upgrading needs no action. The release records its size for each export imported alone, and its bytes by source file, function, and class member, for the package index to show where they go.
- The `homepage` of the manifest, which npm links, is the package page on codenhub.dev, where it was the package's directory on GitHub.
- The reference pages describe index signatures, overloads, and the block tags of members more fully, and their anchors no longer change between builds.

<!-- 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/json-schema.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/forms.md#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 and the Standard Schema every validator now carries: a lone `boolean()` is 1.87 kB gzipped, from 1.18 kB, and an object with a string, an email and an optional integer 6.58 kB, from 5.36 kB. `toJsonSchema` with an object of one string is 6.2 kB, and `portugueseMessages` with `formatIssue` 3.5 kB. Each is the scenario of the bundle-size test, bundled from the published 0.4.0 and 0.3.0 packages as that test bundles `dist/`: minified, then gzipped at level 9.

## 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/custom-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
}
```
