# valibot — corrections from the Stale Priors Index

<!--
     Stale Priors Index · Unattended Works · https://stalepriors.com/rules/valibot.md

     The Stale Priors Index, Unattended Works (https://stalepriors.com/rules/valibot.md), CC BY 4.0. Retrieved 2026-09-11.
     Licence: https://creativecommons.org/licenses/by/4.0/  ·  SPDX-License-Identifier: CC-BY-4.0
     Quotations from third-party changelogs remain their authors'; nothing here grants any
     right in the libraries described. Full map: https://stalepriors.com/data/LICENSE

     Changed since the 2026-09-11 sweep: 3 of the 19 entries below, newest 2026-09-24
     — a change to this record, not a re-check against a source. The note below says why.

     GENERATED FILE — DO NOT EDIT BY HAND. Your edit will be destroyed by the next build.
     Rebuild:  node tools/build-rules.mjs
     Source:   data/valibot/facts.json
-->

For projects on **valibot@^1.2**. 19 corrections, 19 of them dated to the release that changed the behaviour; every entry links the primary source it was verified against. Latest valibot: 1.5.0, verified 2026-09-11. Do **not** use this if you are pinned to valibot@0.x, whose API differs substantially from the v1 line.

**This is the statements only** — no reproduced model failures, no code, no evidence. The full pack has all three: https://stalepriors.com/corrections/valibot.md

**Two dates, and they answer different questions.** The entries below were last checked against their primary sources on 2026-09-11 — that date is the floor, and it is the only verification claim this file makes. Separately, 3 of the 19 entries have had their own text change since that sweep, the newest on 2026-09-24. A text change is a change to the record — a re-scored severity, a corrected release date, a clarified note — and it is **not** a re-check against the source. Do not read the later date as the fresher of two checks; per-entry dates are in `substance_changed_on` in the JSON.

**What was measured, and what this file is not.** The 2 benchmark runs so far measured the **prisma** and **zod** correction packs — not valibot's, and not this shorter file. There is no measurement of what either does for valibot. Every cell: https://stalepriors.com/benchmark

The corrections below are 18,698 characters. Read that as a cost: a rules file is added to requests, so it is a decision to make rather than a default to accept.

## Runs, but is silently wrong

### `cache / cacheAsync (issue paths)`

**Behaviour changed in valibot 1.5.0** (2026-09-09).

On valibot 1.3.0 through 1.4.2, a `cache` or `cacheAsync` schema inside a parent schema reported a wrong issue path whenever it answered from the cache. The cache returned the same issue objects on every hit, and each parent added its path item to them again. So `object({ key: cache(pipe(string(), minLength(3))) })` reports the path `key`, then `key.key`, then `key.key.key` for the same bad input, and `flatten` keys the error the same way. One cached schema shared by two parents reports the first parent's key inside the second's path. A hit needs the same input value, so primitives are what trigger it: a new object is a cache miss. A `cache` at the top level, with no parent, reported stable paths on every release that has it. From 1.5.0 the cache hands out copies of the issues and their paths, and the path is the same on every parse. On 1.3.x or 1.4.x, put `cache` only around a top-level schema, or upgrade.

*The stale belief:* That wrapping a nested schema in `cache` changes only how often it runs, not what it reports. On 1.3.0 through 1.4.2 each cache hit lengthened the issue path.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `email`

**Stricter in valibot 1.5.0** (2026-09-09).

From 1.5.0 the `email` action accepts ASCII letters only. On every earlier release the ladder reaches, 0.42.0 through 1.4.2, `EMAIL_REGEX` used the `i` and `u` flags together. Under Unicode case folding its `\w` and `[a-z]` also match two non-ASCII code points: U+017F LATIN SMALL LETTER LONG S (`ſ`) and U+212A KELVIN SIGN (`K`, which looks like a capital K). So an address with either character in the local part, the domain or the top-level domain passed `email()`. A check of every Unicode scalar value in four positions found these two and no others. They matter after validation: `"\u212A".toLowerCase()` is `"k"` and `"\u017F".toUpperCase()` is `"S"`, so an address that passed can case-map to a different, all-ASCII address. 1.5.0 drops the `i` flag and lists `A-Z` explicitly, and mixed-case ASCII addresses such as `User.Name@Example.COM` still pass. `rfcEmail` rejects both characters on every release that ships it (1.0.0 onward). On a pin below 1.5.0, add an ASCII-only check after `email()`. After upgrading, a stored address that contains either character fails validation.

*The stale belief:* That `email()` has always accepted ASCII letters only, since a non-ASCII address such as `é@example.com` fails on every release. Below 1.5.0 two non-ASCII letters passed: U+017F and U+212A.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `literal / value / values / notValue / notValues (NaN)`

**Behaviour changed in valibot 1.5.0** (2026-09-09).

From 1.5.0 valibot treats `NaN` as equal to itself when it compares values. `literal(NaN)`, `value(NaN)` and `values([NaN])` accept `NaN`, `notValue(NaN)` and `notValues([NaN])` reject it, and `intersect` merges two branches that both produce `NaN` for the same entry, or two invalid `Date` objects. On every earlier release these compared with `===`, and `NaN !== NaN`. So `literal(NaN)` matched nothing, `notValue(NaN)` let `NaN` through, and `intersect` reported that the two outputs could not be merged. This only matters where `NaN` reaches the comparison: through `nan()`, `any()`, `unknown()` or a transform, because `number()` rejects `NaN` on every release. Three things did not move: `+0` and `-0` are still equal, `picklist([NaN])` accepted `NaN` on every release, and on 1.5.0 `value()` with an invalid `Date` still rejects a different invalid `Date` (only the same instance matches). To match `NaN` on any release, use `nan()`.

*The stale belief:* That `literal(NaN)` matches `NaN` and `notValue(NaN)` excludes it on any release. Below 1.5.0 both compared with `===`, so the first never matched and the second never excluded anything.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `strictObject / looseObject / objectWithRest`

**Stricter in valibot 1.5.0** (2026-09-09).

Below 1.5.0, `strictObject` did not report an unknown key named like an `Object.prototype` member, such as `toString`, `valueOf`, `hasOwnProperty`, `constructor` or `__proto__`. The schemas decided whether an input key was declared with `key in entries`, and `in` also finds inherited members. So `strictObject({ name: string() })` accepted `{ "name": "a", "constructor": "x" }` from `JSON.parse` and silently dropped the extra key. `looseObject` dropped such a key instead of passing it through, and `objectWithRest` dropped it without running the rest schema. The async variants did the same. From 1.5.0 the check is an own-property check. `strictObject` rejects all of these keys with a `strict_object` issue. `looseObject` passes `toString` and similar names through, and `objectWithRest` validates them against the rest schema. `constructor`, `__proto__` and `prototype` are still dropped from `looseObject` and `objectWithRest` output without validation, as on every release. Declared entries with these names work on every release. What 1.5.0 changed is the ENTRIES side of the test and not the input side. The input is walked with `for (const key in input)` on every release from 0.42.0 to 1.5.0; only the test against the declared entries moved, from `key in this.entries` to `Object.prototype.hasOwnProperty.call(this.entries, key)`. So the keys the check can reach are the enumerable string keys, own and inherited, and that is unchanged at 1.5.0. Three consequences hold on every release including 1.5.0: an undeclared symbol key is not reported by `strictObject`, is not copied into `looseObject`'s output, and is never handed to `objectWithRest`'s rest schema; an undeclared non-enumerable own string key is not reported by `strictObject` either; and an INHERITED enumerable key, which the caller never set on the object, IS reported by `strictObject` and is dropped by `looseObject`. A symbol-keyed entry cannot be declared, so there is no way to make `strictObject` accept one: it is neither validated nor copied. Do not read 1.5.0 as making `strictObject` reject every key its entries do not declare. On a pin below 1.5.0, reject undeclared keys before `strictObject` sees the input.

*The stale belief:* That below 1.5.0 `strictObject` reported every undeclared key its check could reach. It did not: it accepted any key named like an `Object.prototype` member, because the test against the declared entries was `key in entries`, which also finds inherited members. And note what the corrected belief is NOT. `strictObject` still does not reject every key its entries do not declare, at 1.5.0 or at any release: symbol keys and non-enumerable own keys are outside the check's reach on every release, because the input is walked with `for...in`.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `ulid`

**Stricter in valibot 1.5.0** (2026-09-09).

From 1.5.0 the `ulid` action rejects a ULID above the 128-bit maximum. A ULID is 26 Crockford base32 characters, which can hold 130 bits, so the first character must be `0` to `7`, and the largest valid ULID is `7ZZZZZZZZZZZZZZZZZZZZZZZZZ`. The ULID spec says any larger value should be rejected. On every earlier release the ladder reaches, 0.42.0 through 1.4.2, `ulid()` checked only the alphabet and the length, so `8ZZZZZZZZZZZZZZZZZZZZZZZZZ`, `ABCDEFGHJKMNPQRSTVWXYZ0123` and every other ULID whose first character is `8`, `9` or a letter passed. A strict ULID parser downstream then rejects or overflows on a value that passed validation. On a pin below 1.5.0, add `regex(/^[0-7]/)` after `ulid()`. After upgrading, a stored value that starts with `8` or higher fails validation.

*The stale belief:* That `ulid()` accepts only values a ULID can encode. Below 1.5.0 it accepted any 26 characters from the Crockford alphabet, including values above the 128-bit maximum.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `emoji`

**Behaviour changed in valibot 1.2.0** (2025-11-24).

The regular expression behind the `emoji` validation action carried a ReDoS vulnerability that was fixed in 1.2.0. If you validate user-supplied text with the `emoji` action, require valibot >= 1.2.0. Advice that recommends the action without that version floor exposes the caller to a denial-of-service on attacker-controlled input.

*The stale belief:* That the `emoji` action is safe to run on untrusted, high-volume input at any 1.x version.

*Verified against:* [valibot v1.2.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.2.0)

### `toNumber / toBoolean / toDate / toBigint / toString`

**Added in valibot 1.2.0** (2025-11-24).

valibot ships dedicated string-to-primitive transformation actions since 1.2.0: `toBigint`, `toBoolean`, `toDate`, `toNumber` and `toString`. Use `toNumber` instead of a bare `transform(Number)`: `toNumber` adds a validation issue when the conversion yields `NaN`, whereas `pipe(string(), transform(Number))` returns `NaN` with `success: true` and hands silently-wrong data downstream. **`toBoolean` is the exception and is not a string parser** — it applies JavaScript `Boolean()`, so the string `"false"` converts to `true`. For the common case of a `"true"`/`"false"` query parameter use `parseBoolean` (1.3.0) or validate the literals with `picklist` before transforming.

*The stale belief:* That valibot has no built-in string-to-primitive conversion at all and every such conversion must go through a hand-written `transform(Number)` or `transform((s) => s === "true")`.

*Verified against:* [valibot v1.2.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.2.0)

## Deprecated, or a better API now exists

### `codePoints / minCodePoints / maxCodePoints / notCodePoints`

**Added in valibot 1.5.0** (2026-09-09).

valibot 1.5.0 added four validation actions that count Unicode code points: `codePoints`, `minCodePoints`, `maxCodePoints` and `notCodePoints`. That makes three units of string length in valibot, and they disagree on anything outside the Basic Multilingual Plane. `length`, `minLength` and `maxLength` count UTF-16 code units, so three 😀 count as 6 and fail `maxLength(3)`. The grapheme actions (`graphemes`, `minGraphemes`, `maxGraphemes` and `notGraphemes`, since 1.0.0) count user-perceived characters, so three 👍🏽 count as 3 and pass `maxGraphemes(3)`, though each is two code points. The new actions count what `[...s].length` counts, lone surrogates included: three 😀 are 3 and three 👍🏽 are 6. Use the action whose unit matches the limit you are enforcing. Below 1.5.0, `check((s) => [...s].length <= n)` gives the code-point count, and it gives the same count on every release.

*The stale belief:* That `maxLength` counts characters, so it can enforce a limit defined in code points, and that valibot has no code-point count. `maxLength` counts UTF-16 code units on every release, and the code-point actions arrived in 1.5.0.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `ksuid`

**Added in valibot 1.5.0** (2026-09-09).

valibot 1.5.0 added a `ksuid` validation action and exports its pattern as `KSUID_REGEX`. It checks the shape only: exactly 27 ASCII letters or digits. A KSUID is a 160-bit number, and the largest one encodes as `aWgEPTl1tmebfsQzFP4bxwgy80V`. `ksuid()` does not check the value, so every 27-character string above that passes, `zzzzzzzzzzzzzzzzzzzzzzzzzzz` included, and segmentio's reference parser rejects them as out of range. About 41% of all 27-character alphanumeric strings are above the maximum. The length is fixed and the base62 alphabet (`0-9A-Za-z`) is in ASCII order, so comparing the string with the maximum using `<=` is an exact value check.

*The stale belief:* That valibot has no KSUID validator, so it takes a hand-written regex. On 1.5.0, also that `ksuid()` rejects a string no KSUID can encode. It checks the shape only.

*Verified against:* [valibot v1.5.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.5.0)

### `toCamelCase / toKebabCase / toPascalCase / toSnakeCase`

**Added in valibot 1.4.0** (2026-05-05).

valibot 1.4.0 added case-conversion transformation actions (`toCamelCase`, `toKebabCase`, `toPascalCase`, `toSnakeCase`) and the `isoDateTimeSecond` validation action. It also made `intersect` stop mutating its input, so frozen objects and arrays can be merged.

*The stale belief:* That valibot has no built-in naming-convention conversions, and that `intersect` cannot be used with frozen inputs.

*Verified against:* [valibot v1.4.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.4.0)

### `guard / parseBoolean / isrc / domain / jwsCompact / cache`

**Added in valibot 1.3.0** (2026-03-17).

valibot 1.3.0 added the `guard` transformation action (narrow types with a type predicate), `parseBoolean`, the `isrc`, `domain` and `jwsCompact` validation actions, and a `cache` method that caches schema output by input. It also fixed `creditCard` to accept 13-digit Visa numbers.

*The stale belief:* That domain-name, ISRC and JWS-compact validation, and schema-level output caching, have no built-in support in valibot.

*Verified against:* [valibot v1.3.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.3.0)

### `isbn`

**Added in valibot 1.3.0** (2026-03-17).

valibot ships an `isbn` validation action covering ISBN-10 and ISBN-13, and it is usable from **1.3.0**, not from 1.2.0. The v1.2.0 release notes announce it — the implementation was merged the day before that release — but 1.2.0's `library/src/actions/index.ts` never re-exports it, so the published 1.2.0 package contains no trace of `isbn` in its runtime, its types or its bundle. The export line appears at 1.3.0, and 1.3.0's own release notes do not mention `isbn` at all. On valibot 1.2.x the hand-written check digit is still the only option.

*The stale belief:* That valibot has no ISBN validator at any version and the check digit must be implemented by hand in a custom `check`. (Correct for every release up to and including 1.2.x; wrong from 1.3.0.)

*Verified against:* [valibot v1.2.0 release notes — the announcement the package does not honour](https://github.com/open-circle/valibot/releases/tag/v1.2.0)

### `examples / getExamples`

**Added in valibot 1.2.0** (2025-11-24).

valibot has first-class example values since 1.2.0: the `examples` action attaches them to a schema and the `getExamples` method reads them back. Do not overload `description` or a custom metadata action to carry examples.

*The stale belief:* That valibot's metadata surface is limited to title/description and examples must be stored outside the schema.

*Verified against:* [valibot v1.2.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.2.0)

### `message`

**Added in valibot 1.1.0** (2025-05-06).

valibot ships a `message` method since 1.1.0 that overrides the error message configuration for one schema locally, without touching global config.

*The stale belief:* That per-schema message overrides must be passed argument-by-argument to each action, or set globally with `setGlobalMessage`.

*Verified against:* [valibot v1.1.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.1.0)

### `NanoIdAction / NanoIdIssue`

**Renamed in valibot 1.1.0** (2025-05-06).

The `NanoIDAction` and `NanoIDIssue` interfaces were renamed to `NanoIdAction` and `NanoIdIssue` in 1.1.0, and the old spellings were **kept as exported `@deprecated` type aliases**. Write `NanoIdAction`; `NanoIDAction` still resolves — it is exported from every release from 1.1.0 through 1.4.2 and points at the new interface — so the cost of the stale spelling is a deprecation, not a broken build.

*The stale belief:* That the nano-ID action's types spell the acronym in full caps, as they did up to 1.0.0. Correct for 1.0.0 and below as the only spelling; from 1.1.0 it is the deprecated alias.

*Verified against:* [valibot v1.1.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.1.0)

### `parseJson / stringifyJson`

**Added in valibot 1.1.0** (2025-05-06).

valibot ships `parseJson` and `stringifyJson` transformation actions since 1.1.0. Parsing JSON inside the pipeline makes a malformed string surface as a valibot issue like any other, instead of as a thrown `SyntaxError` you have to catch separately.

*The stale belief:* That JSON must be parsed outside the schema, with its own try/catch, before validation can begin.

*Verified against:* [valibot v1.1.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.1.0)

### `summarize`

**Added in valibot 1.1.0** (2025-05-06).

valibot ships a `summarize` method since 1.1.0 that turns an issue list into a pretty-printable multi-line string. Use it instead of looping over issues to build a human-readable error report.

*The stale belief:* That rendering issues for a human requires a hand-written loop over `result.issues` or a `flatten` call.

*Verified against:* [valibot v1.1.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.1.0)

### `exactOptional`

**Added in valibot 1.0.0** (2025-03-19).

valibot has `exactOptional` and `exactOptionalAsync` since 1.0.0, for a key that may be absent entirely but must not be present and set to `undefined`. `optional` allows both.

*The stale belief:* That valibot cannot express the difference between a missing key and a key explicitly set to undefined.

*Verified against:* [valibot v1.0.0 release notes](https://github.com/open-circle/valibot/releases/tag/v1.0.0)

## Wrong facts about the library

### `github.com/fabian-hiller/valibot`

**Renamed in valibot 1.2.0** (2025-11-24).

valibot's repository is `github.com/open-circle/valibot`. It moved out of the `fabian-hiller` personal namespace into the `open-circle` organisation, and the npm `repository` field flipped with the 1.2.0 release. The old URL still 301-redirects, so a link to it works — but attributing the project to a personal account is wrong about who maintains it.

*The stale belief:* That valibot is a personal project living at `github.com/fabian-hiller/valibot`.

*Verified against:* [npm registry metadata for valibot — repository field per version](https://registry.npmjs.org/valibot)

---

*The Stale Priors Index, Unattended Works (https://stalepriors.com/rules/valibot.md), CC BY 4.0. Retrieved 2026-09-11. Licence: <https://creativecommons.org/licenses/by/4.0/> (`SPDX-License-Identifier: CC-BY-4.0`).*
*Quoted changelog text belongs to its authors; nothing here grants any right in the libraries described.*
*Terms in full: <https://stalepriors.com/data/LICENSE>.*
