@pyreon/validate is two things in one package, layered on Standard Schema — the cross-library protocol implemented natively by Zod 3.24+, Valibot 1.0+, ArkType 2.0+, and any future spec-compliant validator:
DX helpers on top of your validator —
withField,parseReactive,watchValid,formatErrorswork on any Standard Schema. They add the three things the protocol deliberately leaves out: field metadata, reactive parse, and i18n-aware error formatting. Bring Zod / Valibot / ArkType, or anything spec-compliant.Pyreon's own
svalidator — a chainable + function-composition hybrid (s.string().email()orpipe(string(), email())), Standard Schema-native, with primitives, composition, object algebra, coercion, and 30+ built-in checks. No third-party dependency required.
The two halves share one package but tree-shake independently: import only the DX helpers and the s runtime is dropped entirely (a DX-only import measures ~0.5 KB gz). Use either, or mix them freely.
Installation
npm install @pyreon/validatebun add @pyreon/validatepnpm add @pyreon/validateyarn add @pyreon/validateIf you only want the DX helpers on top of an existing validator, add that validator too:
bun add zod # or valibot, or arktype — any Standard Schema validatorIf you want Pyreon's own s validator, no extra dependency is needed — import { s } from '@pyreon/validate'.
Why this exists
Standard Schema is parse-only — its authors deliberately omitted a metadata channel and an i18n channel. But @pyreon/form and @pyreon/feature need:
Field metadata (label, hint, placeholder, i18n keys) bound to the schema, not duplicated in form props.
Reactive parse — re-validate as the user types, with the result usable in JSX and effects.
i18n-aware errors — error messages as translation keys, resolved through the project's
useI18n()tfunction.
@pyreon/validate adds those three things on any Standard Schema validator — and, since v1, also ships the s validator so you can stay in one dependency end-to-end.
Part 1 — DX helpers (works with any validator)
These helpers work on any Standard Schema validator. The s runtime below is optional.
Quick start
// @check
import { z } from 'zod'
import { signal, effect } from '@pyreon/reactivity'
import { useI18n } from '@pyreon/i18n'
import { withField, parseReactive, formatErrors, watchValid } from '@pyreon/validate'
// 1. Attach field metadata to any Standard Schema (here: a Zod schema).
const emailSchema = withField(z.string().email(), {
label: 'Email address',
placeholder: 'you@example.com',
hint: 'We never share your email',
i18nLabel: 'auth.email.label',
i18nHint: 'auth.email.hint',
autoComplete: 'email',
})
// emailSchema is STILL a Zod schema — `.parse()`, `.optional()`, etc. all work.
// 2. Reactively validate a signal-backed input.
const $email = signal('')
const $result = parseReactive(emailSchema, $email)
effect(() => {
const r = $result()
if (r.issues) console.warn('invalid')
else console.log('parsed:', r.value)
})
$email.set('foo@bar.com') // → $result re-derives, .value populated
// 3. i18n-aware error formatting.
const { t } = useI18n()
const messages = formatErrors($result().issues ?? [], t)
// → strings resolved via t('auth.email.required', …), falling back to issue.fallback or issue.message
// 4. Watch validity flips (does NOT fire on every error change).
const submitButton = document.querySelector('button')!
const stop = watchValid(emailSchema, $email, (valid) => {
submitButton.disabled = !valid
})Everything above works identically with Valibot or ArkType — swap the schema constructor, the helpers don't change:
import * as v from 'valibot'
const sameSchema = withField(v.pipe(v.string(), v.email()), { label: 'Email' })
const $sameResult = parseReactive(sameSchema, $email)Field metadata
withField(schema, meta) attaches Pyreon metadata to any Standard Schema validator.
import { withField } from '@pyreon/validate'
const emailSchema = withField(z.string().email(), {
label: 'Email address',
placeholder: 'you@example.com',
i18nLabel: 'auth.email.label',
autoComplete: 'email',
})Available metadata
Every FieldMeta field is optional — consumers (@pyreon/form's useField, @pyreon/feature's defineFeature) read whichever ones they need.
| Field | Type | Use |
|---|---|---|
label | string | Human-readable form label (for <label> elements). |
hint | string | Short helper text under the input. |
placeholder | string | Input placeholder. |
defaultValue | unknown | Initial value used by useForm when the user provides none. |
autoFocus | boolean | Whether the input should auto-focus on mount. |
autoComplete | string | HTML autocomplete token ('email', 'new-password', 'off', …). |
i18nLabel | string | i18n key — overrides label when t resolves it. |
i18nHint | string | i18n key for hint. |
i18nPlaceholder | string | i18n key for placeholder. |
Reading metadata
import { getMeta, resolveMetaField } from '@pyreon/validate'
import { useI18n } from '@pyreon/i18n'
// Direct read — returns FieldMeta | undefined. Be defensive.
const meta = getMeta(emailSchema)
const label = meta?.label ?? 'Email'
// i18n-aware read — prefers t() resolution, falls back to the literal.
const { t } = useI18n()
const i18nLabel = resolveMetaField(emailSchema, 'label', t)
// → t('auth.email.label') when set + resolved, else meta.label, else undefinedresolveMetaField accepts only 'label' | 'hint' | 'placeholder' and consults the matching i18n<Field> key when a t is supplied. It echoes back to the literal when t returns the key unchanged (no translation registered).
Why mutation, not cloning?
withField mutates the original schema rather than cloning it — the returned schema is the same reference. This is intentional:
Callable schemas can't be naively cloned. ArkType's
Typeinstances are callable functions whose~standard.validatedoesthis(input)—thismust be the callable schema itself. A shallowObject.create()clone is not callable and would break ArkType.The slot is invisible. The metadata rides on a
Symbol.for('pyreon.validate.fieldMeta')non-enumerable property, so it's skipped byJSON.stringify,for…in,Object.keys, structured clone, and library-internal schema comparators.Re-wrapping merges.
withField(base, { a })thenwithField(base, { b })produces a schema carrying both — later keys win on collision.
Reactive parse
parseReactive(schema, source) returns a Computed<ParseResult> that re-validates on every source change. The source can be a Signal<T> or a plain () => T accessor (both are callable, so no typeof branching is needed).
import { signal, effect } from '@pyreon/reactivity'
const $email = signal('')
const $result = parseReactive(emailSchema, $email)
effect(() => {
const r = $result()
if (r.issues) showErrors(r.issues)
else submitForm(r.value)
})ParseResult mirrors Standard Schema's result shape exactly — { value } on success, { issues } on failure — so downstream code that already handles StdSchema results just works.
Async validators
For schemas with async refinements (Zod .refine(async), Valibot async pipe), use parseReactiveAsync — it returns a Computed<Promise<ParseResult>>:
import { watch } from '@pyreon/reactivity'
const schema = z.string().refine(async (s) => await checkUnique(s))
const $result = parseReactiveAsync(schema, $username)
watch($result, async (current) => {
const r = await current
showFeedback(r)
})The outer Computed re-evaluates synchronously on source change; the inner Promise resolves when the validator finishes. Rapid input changes produce overlapping in-flight promises — watch naturally drops stale frames, so the latest input wins.
Validity watcher
watchValid(schema, source, callback) fires only on validity transitions (true→false or false→true), not on every error-message change. Use it for form-state hooks that care about "is this OK?" without re-rendering on every keystroke. It returns an unsubscribe function.
import { onUnmount } from '@pyreon/core'
const stop = watchValid(emailSchema, $email, (valid) => {
submitButton.disabled = !valid
})
onUnmount(stop)i18n bridge
@pyreon/validate extends Standard Schema's Issue with optional { code, key, params, fallback } fields. The formatError / formatErrors / formatErrorsByPath helpers resolve issues through your t function:
import { useI18n } from '@pyreon/i18n'
import { formatErrors } from '@pyreon/validate'
const { t } = useI18n()
const messages = formatErrors(result.issues ?? [], t)Resolution order per issue:
issue.keyis set andtis provided andtreturns a non-key string (i.e. the provider actually has a translation) → use the resolved string.issue.fallbackif set.issue.message(always present per the Standard Schema spec).
Native Standard Schema issues from raw Zod / Valibot / ArkType don't carry key / fallback — they fall through to message immediately, with no overhead. So formatErrors works on third-party validators too; passing t only changes behavior for Pyreon-emitted issues.
Per-field error map
For @pyreon/form's Errors shape (Partial<Record<fieldName, string>>), use formatErrorsByPath:
import { formatErrorsByPath } from '@pyreon/validate'
const errorMap = formatErrorsByPath(result.issues ?? [], t)
// → { email: 'Invalid email', password: 'Too short', ... }
// Concatenate colliding paths instead of "first wins":
formatErrorsByPath(issues, t, { joinWith: '; ' })
// → { email: 'invalid; required' }The path is the issue's path segments joined with .. Path-less issues land under the empty-string key (a form-level error). On collision the first issue wins, unless you pass joinWith.
Binding to a form
toFormValidator(schema, t?) adapts any @pyreon/validate schema into the (values) => Record<field, errorMessage> function @pyreon/form expects as its schema option. It runs schema.safeParse, maps each issue's path to a per-field error via formatErrorsByPath (so i18n keys resolve through t), and returns {} on success.
import { useForm, field } from '@pyreon/form'
import { s } from '@pyreon/validate'
import { toFormValidator } from '@pyreon/validate'
import { useI18n } from '@pyreon/i18n'
const { t } = useI18n()
const schema = s.object({
email: s.string().email(),
age: s.number().int().min(18),
})
const form = useForm({
fields: [field('email', ''), field('age', 0)],
schema: toFormValidator(schema, t),
onSubmit: (values) => save(values),
})toFormValidator is designed for a flat object schema whose field names match the form's fields — each issue path is a single segment that becomes the field key. Nested schemas produce dotted keys (user.email) which won't match a flat form field; use a flat schema (or @pyreon/form field arrays) for form binding.
Coexisting with @pyreon/validation adapters
If you're already using @pyreon/validation's per-library adapters, nothing changes — they stay supported:
import { useForm } from '@pyreon/form'
import { zodSchema } from '@pyreon/validation'
import { z } from 'zod'
const form = useForm({
initialValues: { email: '' },
schema: zodSchema(z.object({ email: z.string().email() })),
onSubmit: (v) => save(v),
})@pyreon/validation ships zodSchema / valibotSchema / arktypeSchema (plus the per-field zodField / valibotField / arktypeField). @pyreon/validate's withField works on top of any of them — wrap individual fields with metadata before composing into the form schema. See @pyreon/validation for the per-library adapter details.
Part 2 — the s validator
Pyreon's own validator runtime is opt-in by import. It implements Standard Schema natively, so the DX helpers above work on s schemas with no adapter.
Defining a schema
import { s } from '@pyreon/validate'
const userSchema = s.object({
name: s.string().min(2),
email: s.string().email(),
age: s.number().int().between(0, 150),
role: s.enum(['admin', 'user']),
tags: s.array(s.string()).max(10),
})
const result = userSchema.parse(input)
if (result.ok) {
console.log(result.value) // fully typed { name, email, age, role, tags }
} else {
console.log(result.issues) // ordered list of parse problems
}s.object({ … }) infers its output type from the field schemas — result.value is { name: string; email: string; age: number; role: 'admin' | 'user'; tags: string[] } with no manual annotations. .optional() / .nullish() fields become optional keys.
Chainable or function-composition
The same schemas, two ergonomic surfaces. Use whichever reads better:
import { s, pipe, string } from '@pyreon/validate'
// Chainable (the `s.` namespace, mirrors Zod's `z.`):
const a = s.string().email().min(3)
// Function composition via pipe():
const b = pipe(
string(),
(s) => s.email(),
(s) => s.min(3),
)The chainable path is cheap: each schema's ops compile to one closure on first parse, so chaining doesn't pay method-dispatch cost per parse.
Tree-shaking — keep chaining, let the compiler do it
The chainable s. API is the most ergonomic, but it can't tree-shake its checks: s.string() returns a class whose prototype carries every string-format method (.email(), .url(), .uuid(), …), so the bundle pulls all 17 format regexes whether you use them or not. Chaining fundamentally requires the methods to exist on the object — so instead of making you switch to a second, more verbose API (the Zod-vs-zod/mini split), Pyreon makes the compiler produce the tree-shakeable output.
Opt in once:
// vite.config.ts
import pyreon from '@pyreon/vite-plugin'
export default { plugins: [pyreon({ optimizeValidators: true })] }…and keep writing the beautiful chainable API:
import { s } from '@pyreon/validate'
export const User = s.object({
name: s.string().min(2),
email: s.string().email(),
age: s.number().int().min(0),
})At build time the compiler rewrites each statically-analyzable module-level const X = s.<chain> into a lean, function-composition form that imports only the constructors + actions it uses — so the bundler prunes everything else. The rewrite is verdict-for-verdict identical to the runtime (parity-locked end to end). Measured against the published bundle with Vite/Rollup, a 3-field schema drops ~11 KB → ~6.5 KB gzipped (−41%) with no source change on your side. Conservative + safe: a dynamically-built schema (inside a function, conditionally, or with a non-literal argument) or a .tsx schema simply stays the full runtime — correct, just not pruned.
Under the hood the rewrite targets @pyreon/validate/mini — lean base constructors plus standalone check actions (minLength / email / minValue / integer / …). That's the compiler's emit target rather than the headline API, but it's importable directly as an escape hatch for dynamically-built schemas or non-Vite bundlers. Both .check() (reads like chaining) and point-free pipe() are supported, and mini schemas are Standard Schema-native + byte-identical to the chainable form:
import { object, string, minLength, email, pipe } from '@pyreon/validate/mini'
const User = object({ name: string().check(minLength(2)), email: string().check(email()) })
const name = pipe(string(), minLength(2))Primitives
Every primitive is available both as a s. member and as a standalone named export.
| Constructor | Output type | Notes |
|---|---|---|
s.string() | string | Length + format + substring + transform checks (see below). |
s.number() | number | Rejects NaN. Bounds + integer + multiple-of checks. |
s.boolean() | boolean | Atomic — type guard only. |
s.bigint() | bigint | typeof === 'bigint' (no coercion). Bounds + multiple-of. |
s.date() | Date | Real Date instances only; rejects new Date('nonsense'). .min/.max. |
s.literal(value) | the literal | Matches one string | number | boolean literal via ===. |
s.enum([...]) | union of values | A small set of string | number literals. |
s.nativeEnum(Enum) | E[keyof E] | A VALUE of a TS native enum / const value-object (filters reverse-mappings). |
s.symbol() | symbol | typeof === 'symbol'. |
s.nan() | number | Accepts only NaN. |
s.null() | null | Accepts only null. |
s.undefined() | undefined | Accepts only undefined. |
s.void() | void | Accepts only undefined. |
s.any() | any | Escape hatch — no validation. |
s.unknown() | unknown | Like any but keeps the output opaque. |
s.never() | never | Accepts NO value — every input errors. |
s.custom<T>(check?, msg?) | T | Validated by a user predicate; with no predicate, a pure type assertion. |
s.instanceof(Ctor, msg?) | instance type | Asserts input instanceof Ctor — s.instanceof(File), s.instanceof(URL), … |
s.stringbool(opts?) | boolean | Coerce a boolean-ish string to a real boolean. |
import { s } from '@pyreon/validate'
s.literal('active').parse('active') // → { ok: true, value: 'active' }
s.enum(['red', 'green', 'blue']) // Schema<'red' | 'green' | 'blue'>
enum Role { Admin = 'admin', User = 'user' }
s.nativeEnum(Role).parse('admin') // → { ok: true, value: 'admin' }
s.instanceof(File) // validate an uploaded File
s.custom<`${number}px`>((v) => typeof v === 'string' && v.endsWith('px'))
s.stringbool().parse('yes') // → { ok: true, value: true }
s.stringbool({ truthy: ['si'], falsy: ['no'] })s.stringbool() accepts only strings and only the configured tokens (defaults: true/1/yes/on/y/enabled ↔ false/0/no/off/n/disabled, case-insensitive + trimmed). It's stricter than s.coerce.boolean(), which applies JS truthiness to any input.
String checks
s.string() ships a deep set of checks. Each appends an op and returns this, so they chain.
Length & substring
| Method | Validates |
|---|---|
.min(n) / .max(n) / .length(n) | string length ≥ / ≤ / exactly n |
.nonEmpty() | length ≥ 1 (alias for .min(1)) |
.startsWith(s) / .endsWith(s) / .includes(s) | substring constraints |
.regex(re) | matches a custom RegExp |
Format checks
| Method | Validates |
|---|---|
.email(opts?) | email; precision tiers 'html5' / 'standard' (default) / 'rfc5322' |
.url() | http(s)://… |
.url({ protocol }) | any RFC 3986 absolute URI whose scheme matches protocol (zod 4's option) — e.g. /^[a-z][a-z0-9+.-]*$/i for every scheme |
.uuid() | UUID v1–v5 |
.ip() | IPv4 or IPv6 |
.cidr() | CIDR notation (x.x.x.x/0-32 or …/0-128) |
.phone() | normalized E.164 shape (client/server-split — see below) |
.e164() | strict E.164 (+ then 1–15 digits, first non-zero) |
.creditCard() | 12–19 digits + Luhn checksum |
.cuid() / .cuid2() | CUID v1 / CUID2 |
.ulid() | ULID (Crockford base32, 26 chars) |
.nanoid() | Nano ID (A-Za-z0-9_-) |
.emoji() | one or more emoji code points |
.base64() / .base64url() | standard / URL-safe base64 |
.jwt() | three base64url segments (header.payload.signature) |
.duration() | ISO 8601 duration (P3Y6M4DT12H30M5S, PT1H, …) |
.iso.date() / .iso.dateTime() / .iso.time() | ISO 8601 date / date-time / time |
On iOS and Android (PMTC), both .url() forms lower to the same verdicts as the web — asserted by executing the emitted Swift and Kotlin over a corpus that includes the cases where the three regex engines disagree ($ before a trailing newline, \s and . membership, case folding). A protocol that is not an inline, portable regular-expression literal is not guessed at: the field lowers without a URL check and the compiler warns by name.
import { s } from '@pyreon/validate'
s.string().cuid2().parse('tz4a98xxat96iws9zmbrgj3a') // ok
s.string().ulid() // 01ARZ3NDEKTSV4RRFFQ69G5FAV
s.string().base64().min(4) // composes with length checks
s.string().email({ precision: 'rfc5322' }) // server-grade strictnessString transforms
These run after the type-check, before further checks:
s.string().trim().min(2) // trim first, then enforce length
s.string().toLowerCase()
s.string().toUpperCase()Custom messages & i18n keys
Every check accepts an optional CheckOpts carrying { message, code, key, params, fallback } — the i18n routing the format* helpers resolve:
s.string().min(8, {
message: 'Password too short',
key: 'auth.password.too-short',
params: { min: 8 },
})Number, bigint & date checks
s.number() checks (rejects NaN at the type level):
| Method | Validates |
|---|---|
.min(n) / .gte(n) | ≥ n (inclusive) |
.max(n) / .lte(n) | ≤ n (inclusive) |
.gt(n) / .lt(n) | strictly > / < n (exclusive) |
.between(lo, hi) | inclusive range |
.int() | integer |
.finite() | finite (rejects ±Infinity) |
.positive() / .negative() | > 0 / < 0 |
.nonNegative() / .nonPositive() | ≥ 0 / ≤ 0 |
.multipleOf(n) / .step(n) | divisible by n |
.safe() | within the IEEE-754 safe-integer range (a bounds check, not integer-ness) |
s.bigint() has the parallel surface: .min / .max / .gt / .gte / .lt / .lte / .between / .positive / .negative / .multipleOf / .step (all bigint arguments).
s.date() accepts only valid Date instances and bounds the instant with .min(date) / .max(date) (inclusive).
s.number().int().between(0, 150)
s.number().multipleOf(0.5)
s.bigint().positive().max(1_000_000n)
s.date().min(new Date('2020-01-01'))Composition
| Constructor | Output | Element checks |
|---|---|---|
s.object({ … }) | inferred object | unknown-key policy + object algebra (below) |
s.array(el) | T[] | .min / .max / .length / .nonEmpty |
s.tuple([a, b]) | [A, B] | .rest(schema) for a variadic tail |
s.record(value) / s.record(key, value) | Record<K, V> | validates each own key's value (and key, if given) |
s.map(key, value) | Map<K, V> | .min / .max / .size |
s.set(value) | Set<V> | .min / .max / .size / .nonEmpty |
s.union(a, b, …) | A | B | … | first matching member wins |
s.discriminatedUnion('kind', [...]) | object union | O(1) dispatch on a shared literal discriminant |
s.intersection(a, b) | A & B | both must pass; object outputs shallow-merged |
s.lazy(() => schema) | T | recursive / self-referential schemas |
import { s } from '@pyreon/validate'
// Discriminated union — fast, precise errors.
const event = s.discriminatedUnion('type', [
s.object({ type: s.literal('click'), x: s.number(), y: s.number() }),
s.object({ type: s.literal('key'), code: s.string() }),
])
// Recursive type via lazy() — annotate the type explicitly.
type Tree = { value: number; children: Tree[] }
const tree: import('@pyreon/validate').Schema<Tree> = s.lazy(() =>
s.object({ value: s.number(), children: s.array(tree) }),
)Schemas also carry composition shortcuts as chainable methods:
s.string().array() // ≡ s.array(s.string())
s.string().or(s.number()) // ≡ s.union(s.string(), s.number()) → string | number
objA.and(objB) // ≡ s.intersection(objA, objB) → A & B
s.string().transform(Number).pipe(s.number().positive()) // coerce → validateObject algebra
s.object(...) exposes the full Zod-style algebra. Each returns a new schema (immutable):
| Method | Effect |
|---|---|
.pick([keys]) | keep only the named keys |
.omit([keys]) | drop the named keys |
.partial() | make every field optional |
.required() | inverse of .partial() — unwrap .optional()/.nullish() back to required |
.extend({ … }) | add / override fields |
.merge(other) | merge another object schema's shape (other wins on conflict) |
.keyof() | an enum schema over this object's keys |
Plus the unknown-key policy:
| Method | Behavior |
|---|---|
.strip() | default — unknown keys are dropped from the output |
.strict() | unknown keys are a validation error |
.passthrough() | unknown keys are kept verbatim |
.catchall(schema) | validate every unknown key against schema and keep it (takes precedence) |
const base = s.object({ id: s.string(), name: s.string(), secret: s.string() })
base.pick(['id', 'name']) // { id, name }
base.omit(['secret']) // { id, name }
base.partial() // { id?, name?, secret? }
base.extend({ active: s.boolean() }) // adds `active`
base.strict() // reject unknown keys
base.catchall(s.string()) // unknown keys must be stringsCoercion
s.coerce.* coerces the input via the JS constructor before the type-check, then runs the normal primitive validation on the coerced value. Each coerced schema inherits its base primitive's full check surface.
import { s } from '@pyreon/validate'
s.coerce.number().int().min(0).parse('42') // '42' → 42 → ok
s.coerce.string().parse(123) // 123 → '123'
s.coerce.boolean().parse(1) // 1 → true (JS truthiness)
s.coerce.date().parse('2024-01-01') // → a Date
s.coerce.bigint().parse('100') // → 100nFor string-to-boolean with explicit token mapping (stricter than JS truthiness), prefer s.stringbool().
s.preprocess(fn, schema) is the general form — transform the raw input before schema validates it:
s.preprocess((v) => String(v).trim(), s.string().min(1))Modifiers
| Modifier | Effect |
|---|---|
.optional() | input may be undefined; output T | undefined |
.nullable() | input may be null; output T | null |
.nullish() | input may be null or undefined |
.nonoptional(msg?) | re-require a present value (rejects undefined again) |
.default(value) | fill undefined input with a value (or () => value) |
.transform(fn) | map the parsed value to a new shape (sync or async) |
.refine(fn, opts) | add a custom constraint after type + checks |
.superRefine(fn) | add any number of issues via ctx.addIssue (cross-field) |
.brand<'T'>() | phantom type brand — prevent mixing structurally-identical values |
.describe(text) / .field(meta) | attach description / full FieldMeta (read via getMeta) |
s.string().optional().default('anon')
s.string().transform((s) => s.trim())
s.string().refine((s) => s.length > 0, { message: 'Required', key: 'common.required' })
// Cross-field validation that reports multiple problems:
s.object({ pw: s.string(), confirm: s.string() }).superRefine((v, ctx) => {
if (v.pw !== v.confirm) ctx.addIssue({ message: 'Mismatch', path: ['confirm'] })
})
// Phantom brand — UserId and PostId are both `string` but not interchangeable:
const UserId = s.string().brand<'UserId'>().field(meta) is the chainable equivalent of the standalone withField — both write the same Symbol-keyed slot, so getMeta reads either.
Resilient parsing & readonly
.catch(fallback)
On parse failure, discard the issues this schema produced and substitute a fallback instead of erroring — for config defaults, lenient inputs, and "never throw" boundaries.
import { s } from '@pyreon/validate'
s.number().catch(0).parse('nope') // → { ok: true, value: 0 }
s.string().min(3).catch('x').parse('ab') // → { ok: true, value: 'x' }
// The fallback can be derived from the raw input:
s.string().catch((input) => String(input)).parse(42) // → { ok: true, value: '42' }Terminal regardless of chain position —
.min(3).catch('x')and.catch('x').min(3)behave identically;catchalways gates the final result.Scoped per-schema — a caught field failure is substituted while a sibling's failure still fails the object:
const schema = s.object({
name: s.string(), // no catch
age: s.number().catch(0), // caught
})
schema.parse({ name: 'Ada', age: 'x' }) // → { ok: true, value: { name: 'Ada', age: 0 } }
schema.parse({ name: 123, age: 'x' }) // → { ok: false } (name failed; age was caught)Async-aware — under
parseAsync, a failing async.refine/.serverCheckis caught after the Promise settles.
.readonly()
Freeze the parsed output (Object.freeze, shallow) and mark it Readonly<T> at the type level. Apply last in a chain.
const cfg = s.object({ port: s.number() }).readonly()
const r = cfg.parse({ port: 80 })
// r.value is Readonly<{ port: number }> and Object.isFrozen(r.value) === true
// (r.value as { port: number }).port = 1 // throws in strict modeParse API
Every s schema exposes the same parse surface:
| Method | Returns | Notes |
|---|---|---|
.parse(input) | Result<T> | Never throws — a discriminated union ({ ok: true, value } | { ok: false, issues }). |
.safeParse(input) | Result<T> | Zod-compat alias for .parse. |
.parseOrThrow(input) | T | Returns the value, or throws a ValidationError with the issues attached. |
.parseAsync(input, { context? }) | Promise<Result<T>> | For schemas with async .refine / .transform / .serverCheck. Threads context to server checks. |
.is(input) | boolean | Pure validity check (sync only — async schemas return false). |
['~standard'] | StdSchema contract | Drops into any Standard Schema consumer with no adapter. |
const r = userSchema.parse(input)
if (r.ok) use(r.value)
else show(r.issues)
if (userSchema.is(req.body)) handle(req.body) // boolean fast pathA successful Result may also carry pending — the deferred server checks recorded on the client (see below).
Client / server validation
One shared schema, a thin client and a heavy server — without shipping the heavy code to the browser. This is the part of the design no other validator gives you out of the box.
Format upgrade (automatic)
The lightweight in-bundle format validators (s.string().email(), .phone()) are upgraded to strict server validators the instant the server imports @pyreon/validate/server — strict RFC-5322 email + a disposable-domain blocklist, full E.164 phone. The heavy code is unreachable from the main entry, so it tree-shakes out of the client bundle.
// server entry only (side-effect import):
import '@pyreon/validate/server'
// …now every s.string().email() / .phone() validates strictly here,
// while the client keeps the small, fast in-bundle defaults.@pyreon/validate/server also exports addDisposableDomains(domains), isDisposableEmail(email), strictEmail(value), strictPhone(value), and installServerValidators() for explicit / test-controlled setup.
Any format routes through a registry seam, so you can swap in a stricter validator for any format in place:
import { installFormatValidator } from '@pyreon/validate'
// e.g. tighten ULID validation server-side — every s.string().ulid() now uses it:
installFormatValidator('ulid', (value) => myStrictUlidCheck(value)).serverCheck(key) — the async / privileged tier
For checks that can only run server-side — unique-email, breach-check, DNS-MX, cross-field DB lookups. On the client it's a no-op: the value passes and the deferred check is recorded on Result.pending (so the UX can show a "checking…" affordance). On the server, the validator registered via registerServerCheck(key, fn) runs — sync or async.
// shared schema (client + server)
import { s } from '@pyreon/validate'
const signup = s.object({
email: s.string().email().serverCheck('email-unique', { message: 'Email already taken' }),
})
// CLIENT — cheap checks run; serverCheck deferred:
const r = signup.parse(formData)
if (r.ok && r.pending?.length) showChecking() // 'email-unique' is pending
// SERVER-only module — register the heavy implementation:
import { registerServerCheck } from '@pyreon/validate/server'
registerServerCheck('email-unique', async (value, ctx) => {
const db = (ctx as { db: Db }).db
return !(await db.user.existsByEmail(value as string))
})
// SERVER — run the async checks, threading a context (DB handle / request):
const verdict = await signup.parseAsync(formData, { context: { db } })A registered async check promotes the parse to a Promise, so
parse()returns aparseAsync-directing issue — useparseAsync(input, { context })server-side.The issue
pathis snapshotted at the check site, so a field / array-element check reports the correct path even though it resolves after the path unwinds.serverCheckfields JIT-compile like everything else — the JIT defers async-resolving subtrees onto a pending list its root return awaits, so carrying server checks costs no performance.
JSON Schema emit
toJsonSchema(schema) — from the @pyreon/validate/json-schema subpath — emits a JSON Schema draft 2020-12 document from any s schema: for OpenAPI specs, AI structured-output constraints, editor autocomplete, or cross-language contracts. The subpath keeps the emitter out of validators-only bundles.
import { s } from '@pyreon/validate'
import { toJsonSchema } from '@pyreon/validate/json-schema'
const User = s.object({
name: s.string().min(2),
email: s.string().email(),
age: s.number().int().min(0).optional(),
})
toJsonSchema(User)
// {
// $schema: 'https://json-schema.org/draft/2020-12/schema',
// type: 'object',
// properties: {
// name: { type: 'string', minLength: 2 },
// email: { type: 'string', format: 'email' },
// age: { type: 'integer', minimum: 0 },
// },
// required: ['name', 'email'],
// }The mapping: string formats → standard format keywords (email / uri / uuid / date / date-time / time / duration); regex / startsWith / endsWith / includes → pattern; .int() upgrades to type: 'integer'; unions → anyOf, intersections → allOf, tuples → prefixItems; .strict() → additionalProperties: false, .catchall(s) → additionalProperties: <schema>; .optional() / .nullish() / .default() fields drop out of required; .describe() → description, .default() → default.
Performance
The s runtime is benchmarked against the seven most-used TypeScript validators —Zod 4 (interpreted and through the z.compile() codegen it shipped in 4.5),Valibot 1, ArkType 2, TypeBox (TypeCompiler), typia 14, Yup 1 andJoi 18 (bun bench:validation).
The harness is built for objectivity. Every scenario × path × axis × library cell runs in fresh isolated processes (3 pooled per cell, so the CI covers process-level jitter), those processes are round-robined across the libraries in a row so a load burst widens every CI together instead of landing on one library, a cross-library correctness gate runs before any timing, and every scenario rotates an 8-input pool — with one constant input the call is loop-invariant and V8 hoists it out of the timed loop, which had one competitor's cell reading 3ns/op for work whose regex alone costs more. Each row carries a seeded bootstrap 95% CI; rows inside the winner's CI are marked 🤝 tied. One honest limit is structural: the bench is written and judged by the Pyreon authors (disclosed in its header); every scenario, input, and competitor call form is in the one file for review.
Two axes, because these libraries do not all return the same thing.
check — a boolean verdict (.is())
Fastest or CI-tied on 10 of 12 cells. Outright wins on flat object (31ns, ahead of ArkType 35 and TypeBox 39), array-of-20 (51ns), deep-nested (10ns) and object-with-array-of-objects (46ns); tied 🤝 for first with TypeBox / typia / ArkType / compiled Zod on number-range (both paths), flat-object invalid, deep-nested invalid, and discriminated union (both paths). Both losses are string.email, where ArkType and typia are ~1.3× ahead.
.is() compiles its own verdict-only validator: every failure site is a barereturn false, no output value is built, no issue objects are allocated, and the emitted function takes only the input. Against the previous .is() — which ran the parse validator and discarded its results — that is worth 1.2× to 25.7×, measured as an in-run control interleaved with every other cell in the same run: flat-object invalid 180ns → 7ns, deep-nested invalid 137ns → 7ns, discriminated-union invalid 85ns → 7ns, array-of-20 valid 119ns → 51ns.
parse — produce a validated output value
The error path is ours. In the full 2026-09-23 run (BENCHMARKS.md §9) Pyreon wonevery invalid-input parse cell. The earlier run below had it outright fastest on four of five invalid cells — 1.1–3.4× vs Valibot, 7.3–10.9× vs Zod (interpreted and compiled alike), 12.4–37.8× vs ArkType, 1.6–6.1× vs typia, 19–135× vs Joi/Yup. Error-information parity is verified separately: on a multi-fail object Pyreon reports the same issue count with paths and messages as Zod, so the speed is not "reporting less". The fifth (scalar number-range invalid) goes to typia by 1.2×.
On the valid path we are not ahead, and Zod 4.5 is why. In the 2026-09-23 run compiled Zod won three valid-parse cells outright — email (1.3×), integer (1.7×) and array-of-20 (1.1×) — and ArkType won valid object parse (1.2×); where the older per-cell figures below disagree, that run supersedes them. In the earlier run, compiled Zod wins scalar number-range (2.8ns vs 5.0ns), array-of-20 (108ns vs 116ns) and object-with-array-of-objects (117ns vs 134ns); ArkType and typia win scalar email (26/27ns vs 33ns) and ArkType wins flat object (36ns vs 49ns). Deep-nested and discriminated union are 🤝 CI-ties with compiled Zod. We remain ahead of interpreted Zod, Valibot, Yup and Joi on every valid-path shape.
Those four compiled-Zod cells were re-measured on 2026-09-06 after the parse() seam was made small enough for V8 to inline (S.parse on a scalar: 10.2ns → 4.8ns): number-range 5.0 vs 2.8 (1.80×, was 2.3×), discriminated union 8.7 vs 8.7 (a tie, as before), object-with-array-of-objects 134 vs 117 (1.14×, was 1.17×), array-of-20 116 vs 108 (1.08×, was 1.10×). Only those four cells were re-run, interleaved per cell at a 1-minute load under 2; every other number on this page is still the 2026-08-31 run. On the scalar cell the emitted validator alone now ties compiled Zod's whole call (2.6ns each) — the remaining 2.2ns is the Result envelope. On the two deep cells the residual was attributed to the per-item stripped clone; that read rested on zod-compiled returning its input by reference, which its emitted parser does not do (it builds an object literal per item too). What did differ per array — materialising ctx.path on every parse — is now skipped for pure-inline element subtrees, worth a nominal 5–13% on the object cell and nothing on the 20-item array in two quiet-box runs (CI-overlap); the rest of that gap has no named mechanism yet.
Three structural reasons for the residual, none of them "we are simply slower":
ArkType returns the input aliased on success — it does not clone — while Pyreon (like Zod and Valibot) returns an immutable clone with unknown keys stripped. Strip-and-clone is a deliberate semantic and part of the gap is paying for it.
Zod's compiled path signals failure with a sentinel return value rather than threading a context, which is cheaper on the valid path — and it pays for that by re-running theinterpreter to build errors, which is why its invalid cells are 550–850ns against our 50–180ns.
typia and TypeBox specialise a single type at build time. On a 3ns integer check there is very little a runtime schema library can give back. typia in particular cannot build a validator at runtime at all (see below), which is a different product, not just a faster one.
Ahead-of-time compile cost
A compiler buys steady-state speed with a one-off cost. Across the seven scenarios,z.compile() costs 17–67µs and TypeCompiler.Compile() 11–50µs per schema. Pyreon compiles lazily on first parse and ArkType at schema-definition time, so neither has a separable compile call to report rather than a number measuring something else. A long-lived server amortizes any of these; a cold serverless invocation or a short CLI run may not.
typia is not in that table because it has no runtime compile step at all: it generates its validator from a TypeScript type at build time, through a bespoke toolchain (ttsc + TypeScript 7). That buys it the scalar wins above, and costs it the ability to build a validator from data that only exists at runtime — an OpenAPI-driven client, a user-defined form, a schema loaded from a database.
See also
Standard Schema spec — the cross-library protocol this package builds on.
@pyreon/validation— per-library adapters (zodSchema/valibotSchema/arktypeSchema).@pyreon/form— signal-based forms (consumestoFormValidatoroutput).@pyreon/i18n— the translation provider behindformatErrors.
API reference
DX helpers (any Standard Schema validator)
| Export | Signature | Description |
|---|---|---|
withField | (schema: S, meta: FieldMeta) => S | Attach field metadata to any Standard Schema. Returns the same reference (mutates a Symbol slot); re-wrapping merges. |
getMeta | (schema: S) => FieldMeta | undefined | Read the attached metadata (or undefined). Accepts objects and callable schemas (ArkType). |
resolveMetaField | (schema, field: 'label' | 'hint' | 'placeholder', t?) => string | undefined | Read one field through optional i18n; falls back to the literal. |
parseReactive | (schema, source) => Computed<ParseResult> | Re-validate on every source (Signal or accessor) change. Sync only. |
parseReactiveAsync | (schema, source) => Computed<Promise<ParseResult>> | Async variant; caller handles staleness (e.g. via watch). |
watchValid | (schema, source, cb) => () => void | Fire cb(valid) only on validity flips; returns an unsubscribe. |
formatError | (issue, t?) => string | Resolve one issue: key+t → fallback → message. |
formatErrors | (issues, t?) => string[] | Resolve an array of issues (original order preserved). |
formatErrorsByPath | (issues, t?, { joinWith? }?) => Record<string, string> | Per-field error map keyed by dotted path (for @pyreon/form). |
toFormValidator | (schema, t?) => (values) => Record<string, string> | Adapt an s schema into a @pyreon/form schema validator. |
installFormatValidator | (name, fn) => void | Swap a superior validator for a named format (the client/server seam). |
Types: FieldMeta, Input<S>, Output<S>, ParseResult, ReactiveSource, PyreonIssue, StandardSchemaIssue, StandardSchemaResult, StandardSchemaV1, TFn, WithFieldMeta.
s validator runtime
| Group | Surface |
|---|---|
| Namespace | s.{string, number, boolean, bigint, date, literal, enum, nativeEnum, symbol, nan, null, undefined, void, any, unknown, never, custom, instanceof, object, array, union, discriminatedUnion, record, tuple, map, set, intersection, lazy, coerce, preprocess, stringbool} |
| Standalone | every constructor above is also a named export (string, object, …); reserved words use a _ suffix (null_, undefined_, void_, enum_, instanceof_) |
| Function comp | pipe(schema, ...actions) — apply chain-method steps as functions |
| Coercion | s.coerce.{string, number, boolean, date, bigint} |
| Parse API | .parse · .safeParse · .parseOrThrow · .parseAsync(input, { context? }) · .is · ['~standard'] |
| Modifiers | .optional · .nullable · .nullish · .nonoptional · .default · .transform · .refine · .superRefine · .brand · .describe · .field · .catch · .readonly · .serverCheck |
| Composition methods | .array · .or · .and · .pipe |
| String checks | .min · .max · .length · .nonEmpty · .regex · .startsWith · .endsWith · .includes · .email · .url · .uuid · .ip · .cidr · .phone · .e164 · .creditCard · .cuid · .cuid2 · .ulid · .nanoid · .emoji · .base64 · .base64url · .jwt · .duration · .iso.date · .iso.dateTime · .iso.time · .trim · .toLowerCase · .toUpperCase |
| Number checks | .min · .max · .gt · .gte · .lt · .lte · .between · .int · .finite · .positive · .negative · .nonNegative · .nonPositive · .multipleOf · .step · .safe |
| BigInt checks | .min · .max · .gt · .gte · .lt · .lte · .between · .positive · .negative · .multipleOf · .step |
| Date checks | .min(date) · .max(date) |
| Array checks | .min · .max · .length · .nonEmpty |
| Map/Set checks | .min · .max · .size (Set also .nonEmpty) |
| Object algebra | .pick · .omit · .partial · .required · .extend · .merge · .keyof · .strip · .strict · .passthrough · .catchall |
| Tuple | .rest(schema) for a variadic tail |
| Types | Infer<S> · Input<S> · Output<S> · Result<T> · Schema<T> · SuperRefineCtx · PyreonIssue · ValidationError · PendingCheck |
Configuration and format registry
| Export | Signature | Description |
|---|---|---|
configure | (options: ValidateConfig) => void | Global runtime config. configure({ jit: false }) disables code generation (new Function) for a CSP without 'unsafe-eval'; affects schemas compiled after the call. |
getFormatValidator | (name: string) => FormatValidator | undefined | The validator installed for a named format, if any. |
uninstallFormatValidator | (name: string) => void | Remove an installed format validator (falls back to the built-in). |
resolveFormat | (name, light: FormatValidator) => FormatValidator | The installed validator for name, else light. |
installServerCheck / getServerCheck / uninstallServerCheck | (key, fn?: ServerCheckFn) | The registry behind .serverCheck(key). Apps normally use registerServerCheck from @pyreon/validate/server; these are exported for tests. |
validateCreditCard / validateIp / validatePhone / isIPv6 | (value: string) => boolean | The lightweight client format validators, for standalone use. |
Types: ValidateConfig, FormatValidator, ServerCheckFn ((value, context?) => boolean \| Promise<boolean>), EmailPrecision ('html5' \| 'standard' \| 'rfc5322'), UrlOpts.
Mini entry (@pyreon/validate/mini)
The compiler's tree-shakeable emit target (see "Tree-shaking" above): lean base constructors (object, string, number, …) plus standalone check actions (minLength, email, minValue, integer, …), composed with .check(...) or pipe(schema, ...actions). Verdicts are identical to the chainable form.
JSON Schema entry (@pyreon/validate/json-schema)
| Export | Description |
|---|---|
toJsonSchema(schema, options?) | Emit a JSON Schema document for an s schema (see "JSON Schema emit" above). |
JsonSchema / ToJsonSchemaOptions | The result type and the options type. |
Server entry (@pyreon/validate/server)
| Export | Description |
|---|---|
registerServerCheck(key, fn) | Register the heavy/privileged half of a .serverCheck(key) (never ships to the client). |
addDisposableDomains(domains) | Extend the disposable-email blocklist. |
isDisposableEmail(email) / strictEmail(value) / strictPhone(value) | The strict server validators. |
installServerValidators() | Explicitly install the strict email + phone validators (runs automatically on import). |
uninstallServerCheck(key) | Remove a registered server check. |
Types: ServerCheckFn.