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

# @codenhub/validation

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

> **Experimental:** pre-1.0. The API of 0.1.0 is meant to hold for every 0.1.x release; a change that breaks callers, if one proves necessary, ships as 0.2.0 and is listed in the [changelog](docs/changelog/index.md).
>
> **Migrating from 0.0.1:** 0.1.0 is a rewrite and no call carries over unchanged. The [0.1.0 changelog](docs/changelog/0.1.0.md) maps each old call to its replacement.

## Installation

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

## Usage

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

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

const result = email()(input);

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

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

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

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

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

const result = signup(requestBody);
```

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

Write your own validator by returning `pass(value)` or `fail(...)` from any function, and add rules to an existing one with `refine`, including rules that need to `await` something.

## Documentation

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

## Requirements

- Node.js 22 or newer, or a current browser, worker or edge runtime. The code relies on `Object.hasOwn`, and `url()` on `URL.canParse`.
- ESM-aware package resolution. The package is ESM only and marked `sideEffects: false`, so a bundler removes the validators you do not import. CommonJS code can `require()` it on Node.js 22.12 or newer, which load ES modules that way.

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

## Notes

- A validator returns `{ ok: true, value }` or `{ ok: false, error }`. Bad input is never thrown, except by code the input carries, below; a bad option, such as `string({ min: -1 })`, throws when the validator is created.
- Issues never contain an input value, and messages name types (`Expected number, received string`) instead of echoing values. Text for an issue is built only when you ask for it with `formatIssue`, from a message map you pass: `englishMessages` for the built-in English, which is a separate import so a program that words its own issues does not bundle it, or your own to reword or localize. Keys are another matter: a path leads through the input's own keys, and a strict `object` names each key it does not recognize.
- Rules never rewrite the value unless you ask: `trim`, `lowercase`, `uppercase` and `clamp` are the options that do. `email()` and `url()` are the exception, because the URL parser decides what they name: they produce what it reads, such as `https://example.com/admin` for `https://Example.com/public/../admin`, so a check made later on the value sees what a request or a mail server will.
- Validation is synchronous until a rule returns a promise. The types then say the result must be awaited, and the compiler keeps you from reading it as if it were ready.
- `email()` and `url()` accept public host names only: not `localhost`, IP addresses, or special-use names such as `db.internal` and `printer.local`. `url({ allowLocal: true })` accepts them, and means "any host": it accepts every IP address, public ones included, and does not check ranges. Neither resolves the name, so a public name can still point at a private address.
- Exceptions thrown by your own callbacks propagate. They are bugs, not invalid input. So do exceptions from code inside the input itself: a getter or a `Proxy` trap that throws while a validator reads the value. Data parsed from JSON holds neither.
- A recursive `lazy` validator stops at `maxDepth` levels, 128 by default, and fails with `too_big` instead of exhausting the stack, so deeply nested or cyclic input is reported like any other bad input, as long as the recursion is synchronous. An asynchronous rule inside a recursive schema starts each level it awaits before from a fresh stack, which `maxDepth` does not count: such a schema follows input of any depth, and a cyclic object until memory runs out. Give it a bound of its own, such as rejecting input that is not parsed from JSON, which cannot be cyclic. Nothing else limits how much input is checked: every issue found is kept, so a large list of wrong items is as many issues, and asynchronous rules of every item start at once. Cap the size of untrusted input, and give `array`, `set`, `map` and `record` a `max`, before validating it.

## License

Licensed under Apache-2.0.

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

# Validate values and shapes

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

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

## Installation

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

The package is ESM only and has no dependencies. Everything is imported by name from `@codenhub/validation`; there are no subpath exports.

## A validator is a function

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

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

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

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

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

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

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

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

## Reading the result

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

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

const result = email()(input);

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

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

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

When something is wrong, the validator reports every problem it can find and not just the first, so a form can show all its errors at once. Each issue has a stable `code` to branch on, the `path` to the offending value, and `params` describing what was wrong. [Issues and messages](docs/errors.md) has the details.

## Objects

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

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

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

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

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

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

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

## Combining validators

A few functions combine validators into new ones:

- `optional(validator)`, `nullable(validator)` and `nullish(validator)` accept `undefined`, `null` or both as well.
- `withDefault(validator, value)` replaces a missing value with a default.
- `pipe(a, b, c)` runs validators in order, feeding each the value the previous one produced. This is how you clean a string before checking a format: `pipe(string({ trim: true, lowercase: true }), email())`.
- `refine(validator, check, issue)` adds a rule the validator cannot express, such as two fields having to match, and `transform(validator, convert)` changes the value into another.
- `array`, `tuple`, `record`, `set` and `map` validate collections, and `union`, `discriminatedUnion` and `intersection` choose between or merge validators.

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

## Sync and async

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

## Testing whether a value passes

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

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

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

`is` narrows to the type the validator produces, which is exact for a validator that does not change the value. For one that trims or transforms, read `result.value` from calling it instead. `is` accepts synchronous validators only.

## Next steps

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

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

# Coercion

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

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

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

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

## The validators

There is one for each type that commonly arrives as text. Each takes exactly the options of its strict counterpart, applies them to the converted value, and throws for a bad option when it is created, so `coerceNumber({ multipleOf: 0 })` fails at once just as `number({ multipleOf: 0 })` does.

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

Surrounding whitespace is ignored in the text each accepts.

## What is not converted

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

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

A value that cannot be converted fails with `invalid_type`, and its `params` are `{ expected, received, coerced: true }`. The default message reads "Cannot convert string to number", and the value itself is never in the issue. A value that converts but then breaks a constraint fails with that constraint's own code, as for the strict validator.

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

Date text is read in these ISO 8601 forms: a date `YYYY-MM-DD` alone, or followed by `T` or a space, a time `HH:MM:SS`, an optional fraction of a second, and an optional zone, which is `Z` or an offset written `+HH`, `+HHMM` or `+HH:MM` (or with `-`). This is wider than `datetime()`, which accepts only the `T` separator and `Z` or, with `offset`, `+HH:MM`, because a converter reads what programs and people commonly write while a format checks one exact spelling. Date-only strings and date-times without an offset are both read as UTC, so the result does not depend on the timezone of the machine. Include an offset or `Z` in text you produce when the moment is not UTC.

## Reading a whole environment

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

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

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

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

In Node.js, pass a copy of the environment: `env({ ...process.env })`. `process.env` itself is not a plain object, since its prototype is not `Object.prototype`, and `object` rejects it as it rejects a class instance. A missing variable is `undefined`, which `withDefault` replaces and everything else rejects. A value that repeats, as in `?id=1&id=2`, arrives as an array, so validate it with `array(coerceNumber())`.

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

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

# Custom validators

The built-in validators cover common cases. For everything else you write a validator yourself, and it takes no helper and no registration: a validator is any function that takes an input and returns a result.

## Writing a validator

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

even(4); // { ok: true, value: 4 }
even(3); // { ok: false, error: { issues: [{ code: "not_even", path: [] }] } }
```

The parameter is `unknown` because a validator exists to check data you do not trust yet, so check the type before you use it. The `Validator<number>` annotation is optional, but it makes the compiler check your function against the contract and tells `Infer` what the validator produces.

Because a validator is only a function, yours works everywhere a built-in does: inside `object`, `optional` and `pipe`, and as the first argument of `refine`.

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

// `even` is the validator written above.
const pairs = object({ count: even });
```

### Reporting failures

`fail` takes one or more issues, and throws a `TypeError` for none, since a failure with no issue says nothing. 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`.            |

To report several problems at once, pass several issues:

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

const strongPassword: Validator<string> = (input) => {
  if (typeof input !== "string") {
    return fail({ code: "invalid_type", params: { expected: "string", received: typeof input } });
  }
  const issues: IssueInput[] = [];
  if (!/\d/.test(input)) {
    issues.push({ code: "missing_digit" });
  }
  if (!/[A-Z]/.test(input)) {
    issues.push({ code: "missing_uppercase" });
  }
  const [first, ...rest] = issues;
  return first ? fail(first, ...rest) : pass(input);
};
```

### Building on an existing validator

To reuse a built-in validator and add to it, call it and look at the result. Return its failure unchanged, or continue with the value it produced:

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

const workEmail: Validator<string> = (input) => {
  const result = email()(input);
  if (!result.ok) {
    return result;
  }
  return result.value.endsWith("@example.com") ? pass(result.value) : fail({ code: "not_work_email" });
};
```

For a single extra rule, `refine` does this for you, and `transform` changes the value. A `transform` cannot reject, so a conversion that can fail, such as turning a day into a `Date`, is a validator of your own placed after the wrapped one with `pipe`:

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

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

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

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

## Adding a rule with `refine`

`refine(validator, check, issue?)` runs `check` on the value `validator` produced, and only when `validator` succeeded. It returns a validator of the same type.

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

const notReserved = refine(string({ trim: true }), (name) => name !== "admin", {
  code: "reserved_name",
  message: "That name is reserved",
});
```

The third argument is the issue reported when `check` returns `false`. A string is shorthand for `{ message }`. Without it the issue has code `custom`.

Use `refine` for rules that involve several fields as well, and point the issue at the field the user should fix with `path`:

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

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

`check` only runs when the whole object is valid, so it can rely on `data.password` being a string.

## Asynchronous rules

A rule that needs to wait, such as checking a username against a database, is a function that returns a promise. A `refine` check may return one:

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

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

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

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

Nothing else changes. A validator that holds an asynchronous rule 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.

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

Things to know about asynchronous validation:

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

## What you must not do

- **Do not put the input in an issue.** No `params` value, no `message` built from the input. Issues are logged, and inputs are passwords and tokens.
- **Do not throw for bad input.** Return `fail`. Throw only for a mistake in how the 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.

## Testing a validator

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

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

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

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

# Issues and messages

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

## The failed result

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

`error.issues` lists every problem the validator found, in a fixed order, and is never empty. Its type says so too, so `result.error.issues[0]` is an issue and not `undefined`, even under `noUncheckedIndexedAccess`. A strict `object` reports its unrecognized keys first, then the issues of its properties in the order the shape lists them, whichever finished first.

## The issue

| Field     | Meaning                                                                                                                                     |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`    | A stable string naming the kind of failure. Branch on this, never on message text.                                                          |
| `path`    | Where the invalid value is, from the root of what you validated. Keys are strings and array indexes are numbers. Empty for the root itself. |
| `params`  | The facts behind the failure, such as the limit that was crossed. Present when there are any.                                               |
| `message` | Fixed text, when the validator that reported the issue set one. The built-in validators never do; your own can.                             |

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

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

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

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

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

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

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

## Built-in codes

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

| Code                   | Meaning                                                                                                                        | `params`                                                                                                                                                                                                                                 |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_type`         | The value is not the type expected, or could not be converted to it.                                                           | `expected` and `received`, both type names, and `coerced: true` when a coercing validator could not convert the value.                                                                                                                   |
| `invalid_format`       | A string does not have the required format.                                                                                    | `format`, such as `"email"` or `"regex"`, plus what the rule needs.                                                                                                                                                                      |
| `invalid_value`        | The value has the right type but a value that is not allowed.                                                                  | `expected` for a literal, `options` for a list of values, `unique: true` for a repeat in an array or a set, or, for a number, `type` plus either `format` (`"int"`, `"safeInt"`, `"nonZero"`) or `multipleOf`.                           |
| `too_small`            | Below a minimum: too short, too few items, or too small a number or date.                                                      | `minimum`, `type`, and `inclusive` or `exact` where they apply.                                                                                                                                                                          |
| `too_big`              | Above a maximum, or nested deeper than `lazy` allows.                                                                          | `maximum`, `type` (`"depth"` for `lazy`), and `inclusive` or `exact` where they apply.                                                                                                                                                   |
| `unrecognized_key`     | An object has a property its shape does not list, in strict mode.                                                              | `key`. The issue's path ends at the key.                                                                                                                                                                                                 |
| `invalid_key`          | A key of a `record` or `map` failed its key validator, or its key validator turned it into a key an earlier entry already has. | `issues`: what the key validator found, with paths relative to the key, or for a repeated key one `invalid_value` issue with `unique: true`. The issue's path ends at the key, so it is not mistaken for a problem with the value there. |
| `invalid_intersection` | Both validators of an `intersection` passed, but produced values that cannot be merged into one.                               | None. The issue's path is where the two outputs differ.                                                                                                                                                                                  |
| `invalid_union`        | A value matched none of the options of a `union`, or a tagged union got a missing or unknown tag.                              | For `union`, `issues`: the issues each option found, in order, with paths relative to the union's value. For `discriminatedUnion`, `discriminator` and `options`, and the issue's path is the tag's.                                     |
| `custom`               | The default code of a `refine` check or `fail` call that names no code.                                                        | Whatever the reporter set.                                                                                                                                                                                                               |

`received` names types the same way everywhere: `null`, `array`, `nan`, `infinity`, `date`, `invalid date` for a `Date` holding no moment, `map`, `set`, the class name of an instance, `object` for a plain object or anything that cannot be inspected without throwing, or the `typeof` of anything else. Where a plain object is expected (`object`, `record`, `discriminatedUnion`), one made with a prototype of its own, such as `Object.create({ a: 1 })`, is `non-plain object`.

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

## Turning an issue into text

Validators do not build message text when they fail. That keeps them small, and it means text is a choice you make where you show it. `formatIssue` builds it from a message map, and `englishMessages` is the built-in English one:

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

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

The English wording is a separate value you import, and `formatIssue` does not carry it. A program that words its own issues, or never shows one, does not bundle it, which is about 1 kB gzipped. It also means `formatIssue(issue)` with no map says only "Invalid value", so pass a map wherever you show text.

The text comes from the first of these that exists:

1. The issue's own `message`.
2. An entry for its `code` in the message map you pass as the second argument.
3. The generic "Invalid value".

Two codes carry the issues behind them in `params.issues`. `englishMessages` words `invalid_key` with the first issue the key validator found, as in "Invalid key: Must be at least 3 characters", so a form says why the key is wrong, and words that issue with the map it was given, so an override of `too_small` in a spread of `englishMessages` reaches the key's message too. It words `invalid_union` generically, as "Does not match any of the allowed types", because listing what every option expected reads worse than saying none matched; the per-option issues are in `params.issues` for a message of your own. `flatten` and `standard` use the same wording, so neither lists nested issues separately.

### Rewording and localizing

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

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

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

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

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

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

## Errors for a form

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

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

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

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

## Reading the result

A result is plain data, so it can be logged, sent to a client or stored, and because no issue holds the input, doing so does not leak what was submitted. One caveat: the bounds of a `bigint` or `coerceBigint` validator and the value a `literal` of a bigint expects are bigints in `params`, which `JSON.stringify` cannot serialize, and those of a `date` validator are `Date`s, which it turns into ISO strings. Convert them first if you send issues as JSON.

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

# Standard Schema

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

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

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

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

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

## What `standard` returns

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

`~standard.validate` returns `{ value }` on success and `{ issues }` on failure, and never both. It returns its result directly for a synchronous validator and a promise for an asynchronous one, which the specification allows. The input type is `unknown` and the output type is what the validator produces, so a library that infers types from a Standard Schema gets them.

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

## Messages

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

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

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

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

## A note on functions

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

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

# Validator reference

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

Options are read once, when the validator is created. An option that makes no sense, such as a negative length, a `NaN` bound, or limits no value can satisfy together such as `{ min: 5, max: 2 }`, throws a `RangeError` or `TypeError` at that point, because it is a mistake in your code and not in the input. So does a child that is not a function, such as `object({ name: undefined })` after an import that resolved to nothing: every combinator checks the validators and callbacks it is given, and names the one that is wrong.

## Strings

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

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

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

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

The options, all optional:

| Option       | Meaning                                                                                                                       |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `trim`       | Remove leading and trailing whitespace before the constraints run, and from the output.                                       |
| `lowercase`  | Lowercase the string before the constraints run, and in the output. Cannot be combined with `uppercase`.                      |
| `uppercase`  | Uppercase the string before the constraints run, and in the output. Cannot be combined with `lowercase`.                      |
| `min`        | At least this many characters. Counted in UTF-16 code units, as `String.length` counts them, so an emoji can count as two.    |
| `max`        | At most this many characters.                                                                                                 |
| `length`     | Exactly this many characters.                                                                                                 |
| `pattern`    | Must match this regular expression. The `g` and `y` flags are ignored, so the same validator gives the same answer each time. |
| `startsWith` | Must start with this text.                                                                                                    |
| `endsWith`   | Must end with this text.                                                                                                      |
| `includes`   | Must contain this text.                                                                                                       |

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

| Failure                                      | `code`           | `params`                                                      |
| -------------------------------------------- | ---------------- | ------------------------------------------------------------- |
| Not a string                                 | `invalid_type`   | `{ expected: "string", received }`                            |
| Shorter than `min`                           | `too_small`      | `{ minimum, type: "string" }`                                 |
| Longer than `max`                            | `too_big`        | `{ maximum, type: "string" }`                                 |
| Shorter than `length`                        | `too_small`      | `{ minimum, exact: true, type: "string" }`                    |
| Longer than `length`                         | `too_big`        | `{ maximum, exact: true, type: "string" }`                    |
| Does not match `pattern`                     | `invalid_format` | `{ format: "regex", pattern }`                                |
| Wrong `startsWith`, `endsWith` or `includes` | `invalid_format` | `{ format: "startsWith" \| "endsWith" \| "includes", value }` |

## Numbers

`number(options?)` accepts finite numbers and produces a number. `NaN`, the infinities and every other type fail with `invalid_type`.

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

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

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

The options, all optional:

| Option       | Meaning                                                                                                              |
| ------------ | -------------------------------------------------------------------------------------------------------------------- |
| `clamp`      | `{ min, max }`. Move the value into the range instead of rejecting it, before the constraints run and in the output. |
| `min`, `max` | At least or at most this value, inclusive.                                                                           |
| `gt`, `lt`   | Strictly greater than or less than this value.                                                                       |
| `int`        | A whole number.                                                                                                      |
| `safeInt`    | A whole number a double can represent exactly, that is within `Number.MAX_SAFE_INTEGER`.                             |
| `multipleOf` | A multiple of this positive number, compared as the decimals both are written as, so `0.3` is a multiple of `0.1`.   |
| `nonZero`    | Anything but zero.                                                                                                   |

Positive, negative and their "non-" variants are bounds: positive is `gt: 0`, non-negative is `min: 0`, negative is `lt: 0` and non-positive is `max: 0`. A `NaN` bound, or bounds no finite number can satisfy such as `{ min: 5, max: 2 }`, `{ gt: 1, lt: 1 }` or `{ min: Infinity }`, throws a `RangeError` rather than being ignored. `clamp` throws one for a `NaN` bound, a minimum above its maximum, or a range every value of which breaks a bound, such as `{ clamp: { min: 0, max: 10 }, min: 11 }`, and `multipleOf` throws one unless it is a positive finite number. Bounds that hold numbers but no whole one, such as `{ int: true, gt: 1, lt: 2 }`, are not caught when the validator is created, since checking would add to every consumer of `number`; such a validator rejects every input. `multipleOf` is exact at any size: whole numbers are compared as they are, and anything else as the decimal it is written as, which is what a number parsed from text is. 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`.

| 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`, `safeInt` or `nonZero` | `invalid_value` | `{ type: "number", format: "int" \| "safeInt" \| "nonZero" }` |
| `multipleOf`                  | `invalid_value` | `{ type: "number", multipleOf }`                              |

## Booleans

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

## Bigints

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

Bounds appear in `params` as bigints, which `JSON.stringify` cannot serialize. Convert them first if you send issues as JSON.

## Dates

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

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

## Fixed values

- `literal(value)` accepts exactly one value, compared with `===`, and produces that value with its exact type, so `literal("admin")` produces `"admin"` and not `string`. Any primitive works, and this is how `null` and `undefined` are validated: `literal(null)`. The one exception is `NaN`: `NaN === NaN` is false, so `literal(NaN)` could accept nothing, and it throws a `RangeError` when created.
- `oneOf(values)` accepts any one value of a list and produces their union: `oneOf(["admin", "user"])` produces `"admin" | "user"`. The values may be any primitives `literal` accepts, so `oneOf([true, false, null])` is a three-state flag. The list is copied when the validator is created. An empty list throws a `TypeError` and one holding `NaN`, which no value equals, a `RangeError`, since either is a mistake in the schema.
- `nativeEnum(enumObject)` accepts any value of a TypeScript `enum`. It ignores the reverse-mapping names TypeScript adds to a numeric enum, so only the numbers are values. An enum with no values throws a `TypeError`.

All three fail with `invalid_value`. `literal` reports `{ expected }` and the other two report `{ options }`, the list of accepted values.

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

const role = oneOf(["admin", "user"]);
const version = literal("v1");

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

## Any value, no value, functions and instances

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

## Formats

A format is a validator for a string of a particular shape. Most accept a string and produce it unchanged, so trim or lowercase first with `pipe` when the input may need it. `email` and `url` are the exception: the URL parser, not the text, decides what they name, so they produce what the parser reads, as their sections say. Each is imported on its own, so you only ship the ones you use.

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

| Validator    | Accepts                                                                                                                                                                                                                                                                   | `format`                     |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| `email()`    | An email address with a public domain name.                                                                                                                                                                                                                               | `"email"`                    |
| `url()`      | An absolute URL with an allowed protocol and a public host.                                                                                                                                                                                                               | `"url"`                      |
| `uuid()`     | A UUID of version 1 to 8, or the nil or max UUID, hyphenated, in any case.                                                                                                                                                                                                | `"uuid"`                     |
| `ip()`       | An IPv4 or IPv6 address.                                                                                                                                                                                                                                                  | `"ip"`, `"ipv4"` or `"ipv6"` |
| `datetime()` | An ISO 8601 date-time such as `2026-09-28T14:30:00Z`, on a day that exists.                                                                                                                                                                                               | `"datetime"`                 |
| `isoDate()`  | An ISO 8601 calendar date such as `2026-09-28`, on a day that exists.                                                                                                                                                                                                     | `"date"`                     |
| `hostname()` | A hostname: dot-separated labels of letters, digits and hyphens, the last not a number (all digits, or `0x` and hex digits, which the URL parser reads as an IPv4 address), and punycode (`xn--`) labels that decode. One trailing dot, as in `example.com.`, is allowed. | `"hostname"`                 |
| `hex()`      | One or more hexadecimal digits of any case.                                                                                                                                                                                                                               | `"hex"`                      |
| `base64()`   | Standard base64 with correct padding, as an encoder writes it. The empty string is base64 of no bytes.                                                                                                                                                                    | `"base64"`                   |
| `ulid()`     | A ULID, in any case.                                                                                                                                                                                                                                                      | `"ulid"`                     |
| `nanoid()`   | A Nano ID in its default form: 21 characters of `A-Za-z0-9_-`.                                                                                                                                                                                                            | `"nanoid"`                   |
| `cuid2()`    | A CUID2 identifier.                                                                                                                                                                                                                                                       | `"cuid2"`                    |

`isoDate()` produces a string. To get a `Date`, use `date()` on a `Date` you built yourself.

### `email`

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

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

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

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

### `url`

`url(options?)` requires an absolute URL, so `example.com` and `//example.com` are rejected and no scheme is guessed. It rejects embedded credentials such as `https://user:password@example.com`, always. The text is read by the standard URL parser, every check is made on what it read (the scheme, the credentials and the host), and the value is that reading, written back as the parser writes it. So a check made later on the value, such as an allowed path prefix or a list of blocked hosts, sees the URL a request will reach: `https://Example.com/public/../admin` produces `https://example.com/admin`, a host spelled with fullwidth letters or invisible characters produces the host they spell, an internationalized host is in punycode, an IPv4 host written as `0x7f.1` or `127.1` is `127.0.0.1`, and characters such as `"` and `<` in the path are percent-encoded. That is URL encoding, not HTML escaping: others, such as `'` and `&`, are kept, so escape the value for the context you write it into, as any text, before putting it in HTML. Text holding whitespace or control characters, such as a line break, is rejected rather than cleaned, since a written URL never contains them: trim first with `pipe(string({ trim: true }), url())` when the input may have surrounding spaces. A host longer than 253 characters, the most a domain name can have, is rejected as well, with `allowLocal` too. The options are:

| Option       | Meaning                                                                                                                                                                                                                                                                                                                           |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `protocols`  | Accepted protocols without the colon, in any letter case. Default `["http", "https"]`. The list is copied when the validator is created, and a protocol that is not a scheme name, such as `"https:"`, throws a `TypeError` then, since it would match nothing. So do `javascript`, `vbscript` and `data`, whose URLs run script. |
| `allowLocal` | Accept `localhost`, single-label hosts, every IP address and special-use names such as `db.internal`, which are rejected by default, as for `email`. Default `false`.                                                                                                                                                             |

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

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

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

| Scheme   | Accepted when                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mailto` | It names at least one recipient, and every recipient, in the path and in `to`, `cc` or `bcc`, is an address `email()` accepts. With `allowLocal`, an address may be on any hostname, such as `localhost`. Each recipient is produced as `email()` produces it, with its local part escaped where a mailto needs it, so `mailto:Ada@EXAMPLE.com` produces `mailto:Ada@example.com` and `mailto:ada@exa%6dple.com` produces `mailto:ada@example.com`; field names are produced in lowercase. The query may hold only `to`, `cc`, `bcc`, `subject` and `body`, each as `name=value`: any other field, such as `from` or `reply-to`, fails. `subject` and `body` hold what RFC 6068 allows, letters, digits, `-._~!$'()*+,;:@` and `%` escapes, so a line break is `%0D%0A`, and also `/` and `?`, which a URL query allows, so `body=https://example.com/a?b` passes. `&`, `=`, `#` and a malformed escape such as `%zz` fail. They are produced as written. |
| `tel`    | It is a global number: `+`, digits with `-`, `.`, `(` or `)` between them, then optional `;name=value` parameters whose value holds only the characters RFC 3966 allows (letters, digits, `-_.!~*'()[]/:&+$` and `%` escapes), so never `"` or `<`. A local number with `phone-context` is rejected.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `urn`    | It follows RFC 8141: a namespace of 2 to 32 letters, digits and inner hyphens, a colon, and a non-empty name.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |

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

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

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

### `ip`

`ip(options?)` takes `version`, `"v4"` or `"v6"`, to accept one address family only. Without it both are accepted, and any other value throws a `TypeError` when the validator is created. The `format` in the issue is `"ipv4"` or `"ipv6"` when a version is given, and `"ip"` otherwise.

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

### `datetime`

`datetime(options?)` requires the `T` separator, a time, and `Z`, and rejects days that do not exist, so `2026-02-30T00:00:00Z` fails. The options are `offset`, default `false`, which accepts a UTC offset such as `+02:00` instead of only `Z`, and `precision`, an integer from 0 to 9, which requires exactly that many fractional-second digits (`0` forbids them; without it they are optional). A `precision` outside that throws a `RangeError` when the validator is created. Leap seconds (`23:59:60`) are rejected. `coerceDate` reads more spellings than this, such as a space for the `T`, an offset without its colon or no zone at all, because it converts text rather than checks its form; [Coercion](docs/coercion.md) lists them.

## Objects

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

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

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

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

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

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

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

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

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

## Collections

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

The size options `min`, `max` and `length` are non-negative integers, and each throws a `RangeError` when created with anything else, as do limits no size can satisfy together, such as `min` above `max` or a `length` outside them. `min` and `max` are inclusive. A size failure is `too_small` with `{ minimum, type }` or `too_big` with `{ maximum, type }`, where `type` is `"array"`, `"set"`, `"map"` or `"record"`, and `exact: true` is added for `length`. A record's size is its number of keys.

### `array`

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

- `unique: true` rejects repeats, comparing the validated items the way a `Set` does. Each repeat is reported at its own index as `invalid_value` with `{ unique: true }`, and the first occurrence is kept.
- `unique: (item) => key` compares the key the function returns, so `unique: (user) => user.id` makes ids unique.

Repeats are checked only once every item is valid. A sparse array's holes are validated as `undefined`.

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

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

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

### `tuple`

`tuple(items, options?)` accepts arrays of fixed length in which each position has its own validator, and produces a tuple type. Without `rest` the array must be exactly as long as `items`, and a wrong length fails with `too_small` and `{ minimum, type: "array", exact: true }`, or `too_big` and `{ maximum, type: "array", exact: true }`. With `rest` the array must still have at least as many items as `items`, and only `too_small` is possible, without `exact`. The `rest` option is a validator for every position after the fixed ones, which lets the array be longer.

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

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

### `record`

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

### `set` and `map`

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

## Combining validators

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

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

The accepted value is passed through, and every other value goes to `validator`, so `null` and the empty string are not treated as absent by `optional`. Inside an `object`, a property whose validator can produce `undefined` becomes optional in the inferred type, and is left out of the output when absent from the input.

### `withDefault`

`withDefault(validator, value)` replaces `undefined` with a default, and gives every other value to `validator`, so an invalid value is still rejected and only a missing one is replaced. The default is trusted and is not validated. A function is called every time to produce the default, so pass one for an array or object, which would otherwise be shared by every result. Inside an `object`, the property is then always present in the output type.

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

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

### `fallback`

`fallback(validator, value)` replaces a value that fails `validator` with a fallback, so the result never fails. The fallback is trusted and is not validated, and a function receives the issues that were found, which is the place to log them. A fallback that is not a function is the same value in every result, so pass a function for an array or object, such as `fallback(array(string()), () => [])`, or a change to one result shows up in the next. A function is called with the issues, so a fallback that is itself a function has to be returned from one: `fallback(validator, () => callback)`. This turns bad input into a valid-looking value, so reserve it for data where a sensible default is safer than an error, such as a stored preference that may be out of date.

### `pipe`

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

### `transform`

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

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

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

### `refine`

`refine(validator, check, issue?)` adds a rule that `validator` cannot express. It runs only when `validator` succeeded, and receives the value `validator` produced. `check` returns `true` for an acceptable value. The optional `issue` says how a rejection is reported: a string is the message, and an object can set a `code`, `path`, `params` and `message`. Without it the issue has code `custom`. A `check` written as a type guard, such as `(value): value is string => typeof value === "string"`, narrows the output type to what it guards.

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

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

A `check` that returns a promise makes the result asynchronous; see [Custom validators](docs/custom-validators.md#asynchronous-rules).

## Choosing between validators

### `union`

`union(options)` accepts a value that passes any one of several validators and produces what the first one that accepts it produced, so put the more specific options first. A value that none accepts fails with one `invalid_union` issue at the value's own location, and `params.issues` lists, for each option in order, the issues it found. Those paths are relative to the value the union received.

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

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

### `discriminatedUnion`

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

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

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

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

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

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

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

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

### `intersection`

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

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

## Recursive data and JSON

### `lazy`

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

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

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

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

Each level of nesting is one level of recursion, which the JavaScript stack can only hold so many of, so `lazy` counts them. `lazy(getter, { maxDepth })` takes the most levels of `lazy` that may be open at once, 128 by default, counting every `lazy` validator and not only that one, and a value found deeper fails with `too_big` and `{ maximum, type: "depth" }` at its own path. One count for every `lazy` is what bounds the stack when two recursive validators call each other, and it means a limit is checked against every level already open: `lazy(getter, { maxDepth: 1 })` used inside any other `lazy` fails at once, because the outer one is already one level deep. Set `maxDepth` for the whole nesting, not for one validator's share of it. A request body of thousands of nested arrays and a cyclic object, which a recursive validator would follow forever, both come back as that failure and never throw. The count is of `lazy` calls on the stack, so it bounds recursion that happens in one synchronous run, where the stack can overflow. A rule that awaits before it reaches the next level starts that level from a fresh stack and is not counted, so `maxDepth` does not bound an asynchronous recursive schema at all: it follows input nested to any depth, and a cyclic object forever, until the process runs out of memory, while it keeps other work from running. Give such a schema a bound of its own. Input parsed from JSON cannot be cyclic, so a schema that only ever sees parsed JSON needs a size cap on the text, below; one that can be handed live objects needs a cycle check before it runs. Raise `maxDepth` only for data you know is deeper, and only as far as the stack of your runtime holds for the validators you wrote. `maxDepth` must be a positive integer, or `lazy` throws a `RangeError` when created.

The limit is about the stack and not about size, so it does not stop a large flat input: cap the size of untrusted input, for instance with `pipe(string({ max: 100_000 }), json(category))`, and give `array` a `max`.

### `json`

`json(validator?)` accepts text that holds JSON, parses it, and then gives the parsed value to `validator` when there is one. A non-string fails with `invalid_type`, and text that is not JSON fails with `invalid_format` and `{ format: "json" }`. Without a validator the result is `unknown`. Paths of issues from `validator` are relative to the parsed value.

## Reusing shapes

A shape is an ordinary object, so it is reused with ordinary JavaScript: spread one into another to extend it, and leave keys out with destructuring. `partial(shape)` returns a new shape with every property wrapped in `optional`, for an update where any field may be left out. The original shape is unchanged, so the required version is still there to use.

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

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

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

## Coercing text input

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

## Exposing a validator to other libraries

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

## Working with results

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

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

### `Infer`

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

### `is`

`is(validator, input)` returns whether `input` passes, and narrows it to the validator's output type. It accepts synchronous validators only, and throws a `TypeError` if the validator turns out to return a promise. The narrowing is exact for a validator that does not change the value; for one that trims, clamps or transforms, read `result.value` from calling the validator.

### `pass` and `fail`

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

### Types

`Validator<T>`, `AsyncValidator<T>`, `AnyValidator`, `ValidationResult<T>`, `ValidationOk<T>`, `ValidationErr`, `ValidationFailure`, `ValidationIssue`, `ValidationIssueCode`, `ValidationPathSegment`, `IssueInput`, `Composed`, `Shape`, `InferShape`, `StringOptions`, `NumberOptions`, `BigintOptions`, `DateOptions`, `EmailOptions`, `UrlOptions`, `IpOptions`, `DatetimeOptions`, `ObjectOptions`, `ArrayOptions`, `TupleOptions`, `SizeOptions`, `LazyOptions`, `RefineIssue`, `InferTuple`, `InferRecord`, `InferDiscriminated`, `Variants`, `PartialShape`, `LiteralValue`, `EnumLike`, `Constructor`, `AnyFunction`, `Messages`, `FlattenedErrors` and `StandardSchemaV1` are exported for annotating your own code. Each is documented in the source and listed in the [API reference](docs/reference/index.md), and the ones you meet in everyday use are explained in [Custom validators](docs/custom-validators.md) and [Issues and messages](docs/errors.md).

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

# 0.1.0

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

## Added

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

## Changed

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

## Removed

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

## Migrating from 0.0.1

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

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

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

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

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

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