pyreon

@pyreon/validate — API Reference

Generated from validate's src/manifest.ts — the same source that powers llms.txt and MCP get_api. Do not edit this page by hand; edit the manifest. For the conceptual guide, see validate.

Pyreon-owned validator library implementing Standard Schema (https://standardschema.dev) natively. Hybrid API: chainable methods (s.string().email().min(3)) AND function composition (pipe(string(), email(), min(3))) — same schemas, different ergonomic surface. The chainable path doesn't pay class-overhead per parse: each schema's ops compile to a single closure on first call. Includes built-in DX helpers (withField / parseReactive / formatErrors) that ALSO work on top of any other Standard Schema validator (Zod 3.24+, Valibot 1.0+, ArkType 2.0+) — backward-compatible with existing schema code. v1 surface: string/number/boolean/bigint/date/literal/enum/symbol/null/undefined/void/any/unknown primitives + object/array/tuple/record/union/discriminatedUnion/intersection/map/set/lazy composition + optional/nullable/nullish/default/transform/refine/brand/describe modifiers + coercion + 20+ built-in checks. Object algebra: .pick / .omit / .partial / .required / .extend / .merge / .keyof + unknown-key policy (.strip / .strict / .passthrough / .catchall). Performance (bench:validation — TWO axes over 7 COMPETITOR libraries in 8 competitor entries (plus pyreon and a pyreon-legacy-is control = 10 total): zod 4.5 interpreted AND through the z.compile() codegen it shipped in 4.5, valibot 1, arktype 2, typebox 0.34 (TypeCompiler), typia 14 (ahead-of-time codegen from a TS type), yup 1, joi 18; per-cell process-isolated with 3 processes ROUND-ROBINED across the libraries in a row, an 8-input rotated pool per scenario so no call is loop-invariant, a cross-library correctness gate, seeded bootstrap CI95 with tie detection; author-written-and-judged, disclosed in the bench header). RE-MEASURED 2026-08-31 on a settled machine (1-min load 5.18; 5/15-min averages 13.9/62.5 — quieter than the earlier runs, not an idle box) — Zod 4.5 shipped a compiler, so the earlier 'fastest or CI-tied on every shape; never behind' claim no longer holds and is not restated. check axis (boolean verdict, .is()): FASTEST or CI-tied on 10 of 12 cells — outright on flat object (31ns vs ArkType 35 / 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); the two losses are scalar-email (ArkType + typia ~1.3x). parse axis, INVALID path: outright fastest on FOUR of five shapes (1.1-3.4x Valibot, 7.3-10.9x Zod interpreted AND compiled, 12.4-37.8x ArkType, 1.6-6.1x typia, 19-135x Joi/Yup), with verified error-information parity vs Zod; scalar number-range invalid goes to typia by 1.2x. parse axis, VALID path: ahead of interpreted Zod / Valibot / Yup / Joi on every shape, but BEHIND compiled Zod on scalar number-range (3 vs 7ns), array-of-20 (108 vs 119ns) and object-with-array-of-objects (118 vs 138ns), behind ArkType+typia on scalar-email (26/27 vs 33ns) and behind ArkType on flat object (36 vs 49ns); deep-nested and discriminated union are CI-ties with compiled Zod. Two structural reasons for the residual: ArkType returns the INPUT aliased (no clone) while Pyreon returns an immutable stripped clone (a deliberate semantic), and Pyreon's Result envelope is built by the parse() seam AROUND the generated function rather than inside it — pushing it into the codegen is the identified next lever and is not done. Ahead-of-time compile cost, for the two libraries that expose one: z.compile() 17-67us and TypeCompiler.Compile() 11-50us per schema across the seven scenarios; typia has no runtime compile step at all (it generates from a TS type at build time via ttsc, and so cannot build a validator from data that only exists at runtime). .is() compiles its OWN verdict-only validator (tryCompileJitCheck): every failure site is a bare return false, no output value, no issue objects, no ctx — worth 1.2x-25.7x over the previous .is() (which ran the parse validator and discarded its results), measured as an in-run interleaved control; shapes it cannot express (a _runInto fallback, or a check with neither an inline condition nor a _pred predicate) are refused and keep the previous path. The JIT (core/jit.ts) SHIPS and auto-applies on first .parse(): it recursively flattens pure object/array/primitive/discriminated-union shapes to one monomorphic new Function with static-path elision (ctx.path untouched on the valid path; full issue paths reconstructed only at failure sites) and async-aware fallback deferral (async .refine/.transform/.serverCheck subtrees park on a pending list the root return awaits), falling back to the interpreter per-subtree for anything it can't inline (plain union/record/tuple/coerce/…). A fully-inline tree (no fallback ⇒ provably no user code, no Promise return, no pending) is branded _jitPure, and the public seams (parse / ~standard.validate / is) then REUSE a per-schema parse context — zero per-parse ctx/issues-array allocation on the monomorphic hot loop (semantics byte-identical to the general seam; locked by a 10,000-case pure-seam differential fuzz). A discriminated union compiles to a raw-value discriminant switch (=== is exactly the interpreter's Map.get SameValueZero for every tag except NaN — a NaN-tagged union keeps the captured-Map dispatch) with member object bodies inlined, and all-inline-primitive object bodies construct their strip-clone as ONE object literal (final shape in a single allocation). Both paths are byte-identical, locked by FOUR JIT↔interpreter differential fuzz suites (sync inline kinds, the partial-inline seam, async trees, discriminated-union roots). The hot email 'standard' tier runs a table-driven charcode scanner (~1.6× the Zod-parity regex, exhaustive+fuzz equivalence-locked).

Features

  • withField(schema, meta) — attach label/hint/placeholder/i18n keys to ANY Standard Schema validator

  • getMeta(schema) / resolveMetaField(schema, field, t) — read metadata, optionally i18n-resolved

  • parseReactive(schema, signal) — Computed<ParseResult> that re-derives on signal changes

  • parseReactiveAsync — async variant for schemas with async refinements

  • watchValid(schema, signal, callback) — fires on validity transitions, not every error change

  • formatError / formatErrors / formatErrorsByPath — i18n-key-aware error rendering

  • Works with any Standard Schema validator (Zod 3.24+, Valibot 1.0+, ArkType 2.0+, …)

  • Tree-shakeable — DX helpers alone are ~0.5KB gz (measured); the v1 validator runtime (~3.5KB gz) is pulled in only when s / primitives are imported

  • Client/server split — ONE shared schema, thin client + heavy server. s.string().email() validates strictly server-side (rfc5322 + disposable blocklist, full E.164 phone) the instant @pyreon/validate/server is imported — the heavy code tree-shakes out of the client bundle. .serverCheck(key) is the async/privileged tier (unique-email, breach-check, MX): a no-op on the client (recorded on Result.pending), the registered validator (via registerServerCheck) on the server. parseAsync(input, { context }) threads a DB handle / request to the server checks.

  • String format checks: email / url / uuid / ip / phone / creditCard + cuid2 / ulid / nanoid / emoji / base64 / jwt + ISO date/dateTime/time — every format routed through the client/server registry seam, so a server can upgrade any of them in place via installFormatValidator.

  • .catch(fallback) — resilient parse: on failure, discard issues and substitute a static or input-derived fallback (terminal regardless of chain position; sync + async). .readonly() — freeze the parsed output + Readonly<T> at the type level.

Complete example

A full, end-to-end usage of the package:

import { z } from 'zod'
import { signal } from '@pyreon/reactivity'
import { useI18n } from '@pyreon/i18n'
import {
  withField,
  getMeta,
  parseReactive,
  watchValid,
  formatErrors,
} from '@pyreon/validate'

// 1. Attach Pyreon field metadata to any Standard 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 — every Zod method works:
emailSchema.parse('foo@bar.com')          // → 'foo@bar.com'
emailSchema['~standard'].validate('bad')  // → { issues: [...] }

// Read the metadata back:
getMeta(emailSchema)
// → { label: 'Email address', placeholder: ..., i18nLabel: ..., ... }

// 2. Reactively parse a signal-backed input.
const $email = signal('')
const $result = parseReactive(emailSchema, $email)

effect(() => {
  const r = $result()
  if (r.issues) console.warn('invalid:', r.issues)
  else console.log('parsed:', r.value)
})

$email.set('foo@bar.com')   // → $result fires, valid

// 3. Watch for validity flips (no fire on error-message change).
const stop = watchValid(emailSchema, $email, (valid) => {
  submitButton.disabled = !valid
})

// 4. i18n bridge — resolve issue keys to translated strings.
const { t } = useI18n()
const messages = formatErrors($result().issues ?? [], t)
// → translated strings via t(issue.key, issue.params), with fallback to issue.fallback or issue.message

// 5. Works with ANY Standard Schema validator — drop-in swap to Valibot:
import * as v from 'valibot'
const sameSchema = withField(v.pipe(v.string(), v.email()), { label: 'Email' })
const $sameResult = parseReactive(sameSchema, $email)

Exports

SymbolKindSummary
withFieldfunctionAttach Pyreon field metadata (label, hint, placeholder, i18n keys, autoFocus, autoComplete, defaultValue) to any Standar
getMetafunctionRead the Pyreon field metadata attached via withField().
resolveMetaFieldfunctionRead a metadata field through optional i18n.
parseReactivefunctionReactively parse source through schema.
parseReactiveAsyncfunctionAsync variant of parseReactive.
watchValidfunctionSubscribe to validity transitions.
formatErrorfunctionResolve a single issue to a human-readable string.
formatErrorsfunctionResolve an array of issues to strings via the same per-issue logic as formatError.
formatErrorsByPathfunctionBuild a per-field error map keyed by the issue's path joined with ..
toJsonSchemafunctionEmit a JSON Schema (draft 2020-12) document from an s schema — for OpenAPI specs, AI structured-output constraints, ed
serverCheckfunctionDeclare a server-only validation step on a shared schema — the async/privileged tier of the client/server split (unique-
registerServerCheckfunctionRegister the heavy/privileged half of a .serverCheck(key) — the implementation that must NEVER reach the client bundle
catchfunctionOn parse FAILURE, discard the issues this schema produced and return a fallback instead of erroring — resilient parsing
readonlyfunctionFreeze the parsed output and mark it Readonly<T> at the type level (Zod's .readonly).
arrayfunctionWrap this schema in an array — s.string().array()s.array(s.string()) (Zod's .array).
orfunctionUnion this schema with another — a.or(b)s.union(a, b) (Zod's .or).
andfunctionIntersect this schema with another — a.and(b)s.intersection(a, b) (Zod's .and).
pipefunctionValidate with this schema, then feed the (validated, transformed) output into target (Zod's .pipe).
superRefinefunctionLike .refine, but the callback may add ANY number of issues (or none) via ctx.addIssue({ message, path? }) — for cro
preprocessfunctionTransform the raw input BEFORE schema validates it (Zod's z.preprocess) — for trim/coerce/normalize that must happen
nonoptionalfunctionReject undefined (Zod 4's .nonoptional) — re-requires a present value, e.g.
stringboolfunctionCoerce a boolean-ish STRING to a real boolean (Zod 4's z.stringbool).
neverfunctionAccepts NO value (Zod's z.never) — every input is a validation error, including undefined.
customfunctionEscape-hatch validated by a user predicate (Zod's z.custom<T>).
instanceoffunctionAsserts input instanceof Ctor (Zod's z.instanceof).
nativeEnumfunctionValidate a VALUE of a TS native enum (or a const value-object) — Zod's z.nativeEnum.

API

withField function

<S extends StandardSchemaV1>(schema: S, meta: FieldMeta) => S

Attach Pyreon field metadata (label, hint, placeholder, i18n keys, autoFocus, autoComplete, defaultValue) to any Standard Schema. The returned schema is the SAME REFERENCE as the input — Pyreon mutates a Symbol-keyed non-enumerable slot in place, which is invisible to JSON serialization, for…in, Object.keys, and library-internal comparators. Mutation (instead of cloning) is required because ArkType's Type instances are callable functions whose ~standard.validate does this(input) — a shallow clone would not be callable and would break that contract. Re-wrapping merges new metadata onto existing (later keys win).

Example

const emailSchema = withField(z.string().email(), {
  label: 'Email address',
  placeholder: 'you@example.com',
  i18nLabel: 'auth.email.label',
  autoComplete: 'email',
})

Common mistakes

  • Expecting withField to return a NEW reference — it doesn't. The metadata mutation is in place. If you need an isolated copy, construct two separate schemas instead.

  • Adding i18nLabel without a corresponding label — without a translation provider (or when t echoes the key), there's no fallback. Always set both.

  • Storing schemas with metadata in JSON.stringify-d state and round-tripping — the metadata is Symbol-keyed and won't survive serialization. Re-attach on load.

See also: getMeta · resolveMetaField · StandardSchemaV1


getMeta function

<S extends StandardSchemaV1>(schema: S) => FieldMeta | undefined

Read the Pyreon field metadata attached via withField(). Returns undefined for schemas that haven't been wrapped — consumers should be defensive (getMeta(schema)?.label ?? fallback). Accepts both objects AND functions (ArkType's Type instances are callable).

Example

const meta = getMeta(emailSchema)
const label = meta?.label ?? humanize(fieldName)

Common mistakes

  • Metadata is a Symbol slot on ONE schema object; modifiers create NEW instances that don't copy it — withField(s.string(), {...}).optional() returns an OptionalSchema without the slot, so getMeta yields undefined. Attach metadata to the OUTERMOST schema (after .optional()/.transform())

  • getMeta returns undefined for any un-withFielded schema — always be defensive: getMeta(x)?.label ?? fallback

  • resolveMetaField only handles label / hint / placeholder — other i18n keys aren't resolvable through it

  • i18n resolution needs a t that does NOT echo the key — without t (or when t returns the key unchanged) it falls back to the literal metadata value

See also: withField · resolveMetaField


resolveMetaField function

<S extends StandardSchemaV1>(
  schema: S,
  field: 'label' | 'hint' | 'placeholder',
  t?: TFn,
) => string | undefined

Read a metadata field through optional i18n. If the metadata has an i18n<Field> key AND a t function is provided AND t resolves it (returns a non-key string), the resolved string wins. Otherwise falls back to the literal. Recommended over getMeta(schema)?.label directly when you have a t from useI18n().

Example

const label = resolveMetaField(emailSchema, 'label', t)
// → t('auth.email.label') if set + resolved, else meta.label, else undefined

See also: getMeta · formatErrors


parseReactive function

<S extends StandardSchemaV1>(
  schema: S,
  source: Signal<unknown> | (() => unknown),
) => Computed<ParseResult>

Reactively parse source through schema. Returns a Computed<ParseResult> that re-validates on every source change. Synchronous only — for schemas with async refinements (Zod .refine(async), Valibot async pipe), use parseReactiveAsync (this sync variant surfaces an actionable issue if the schema returns a Promise).

Example

const $email = signal('')
const $result = parseReactive(emailSchema, $email)

effect(() => {
  const r = $result()
  if (r.issues) showError(r.issues)
  else commitValue(r.value)
})

$email.set('foo@bar.com')  // $result re-derives

Common mistakes

  • Using parseReactive on an async schema — it surfaces a clear "use parseReactiveAsync" issue rather than silently producing a Promise as the validation result.

  • Calling parseReactive on every render of a component — it allocates a Computed; cache it at component setup time (call once per signal-source pair).

See also: parseReactiveAsync · watchValid · formatErrors


parseReactiveAsync function

<S extends StandardSchemaV1>(
  schema: S,
  source: Signal<unknown> | (() => unknown),
) => Computed<Promise<ParseResult>>

Async variant of parseReactive. The outer Computed re-evaluates synchronously on source change; the inner Promise resolves once the validator finishes. Stale results are superseded automatically — each re-run bumps an internal version, and a validation that finishes after a newer one started resolves to the NEWEST run's result, so an awaited stale frame can never deliver a stale verdict.

Example

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

Common mistakes

  • Adding your own debounce/version counter to guard against stale results — unnecessary: each re-run bumps an internal version and a stale frame's promise FORWARDS to the newest run's result, so an awaited stale frame resolves to the LATEST run's verdict. What it does NOT do is abort the in-flight validator (no AbortSignal — a slow async refine still runs to completion; only its result is superseded)

  • Read source synchronously at the top of your accessors — the async computed tracks only the source read that runs before the first await; a signal read placed after an await won't re-trigger

  • Don't call it per render — it allocates a Computed; create it once per (schema, source) pair at setup

  • The ~standard path it uses drops the Pyreon pending server-check info — call schema.parseAsync() directly if you need to surface deferred server checks

See also: parseReactive


watchValid function

<S extends StandardSchemaV1>(
  schema: S,
  source: Signal<unknown> | (() => unknown),
  callback: (valid: boolean) => void,
) => () => void

Subscribe to validity transitions. The callback fires only when validity flips (true→false or false→true), NOT on every error-message change — ideal for form-state hooks that care about "is this OK?" without re-rendering on every typo. Returns an unsubscribe function. Internally a watch() over parseReactive.

Example

const stop = watchValid(emailSchema, $email, (valid) => {
  submitButton.disabled = !valid
})

onUnmount(stop)

See also: parseReactive


formatError function

(issue: StandardSchemaIssue | PyreonIssue, t?: TFn) => string

Resolve a single issue to a human-readable string. Resolution order: (1) issue.key + t provided AND t returns a non-key string → resolved string; (2) issue.fallback if set; (3) issue.message (always present per StdSchema spec). Native StdSchema issues without key/fallback fall through to message immediately — no overhead.

Example

const message = formatError(issue, t)
// → t('validate.string.too-short', { min: 2 }) when key + t resolve
// → issue.fallback ('Must be at least 2 characters') when t echoes the key
// → issue.message (raw lib message) when no key at all

See also: formatErrors · formatErrorsByPath


formatErrors function

(issues: ReadonlyArray<StandardSchemaIssue | PyreonIssue>, t?: TFn) => string[]

Resolve an array of issues to strings via the same per-issue logic as formatError. Returns strings in the original order so paths line up with the input array.

Example

const { t } = useI18n()
const messages = formatErrors(result.issues ?? [], t)

Common mistakes

  • Resolution order is strict — issue.key + a t that returns a NON-key string resolves; else issue.fallback; else issue.message. WITHOUT a t argument i18n keys never resolve and it drops straight to fallback/message

  • A missing translation is SILENT: if t(key) echoes the key back (no entry) it falls through to fallback/message rather than showing the raw key — always set a fallback alongside a key

  • Native Zod/Valibot/ArkType issues carry no key/params/fallback, so it returns their raw .message regardless of t — only Pyreon-issue shapes route through i18n

  • params are the SECOND arg of t(issue.key, issue.params) — your i18n interpolation must read them from there

  • formatErrorsByPath keeps only the FIRST issue per path unless you pass joinWith, and path-less/form-level issues land under the '' key

See also: formatError · formatErrorsByPath


formatErrorsByPath function

(issues: ReadonlyArray<StandardSchemaIssue | PyreonIssue>, t?: TFn, options?: { joinWith?: string }) => Record<string, string>

Build a per-field error map keyed by the issue's path joined with .. Compatible with @pyreon/form's Errors shape (Partial<Record<fieldName, string>>). Path-less issues land under the empty-string key. First issue wins on collision unless joinWith is set (then messages concatenate).

Example

const errorMap = formatErrorsByPath(result.issues ?? [], t)
// → { email: 'Invalid email', password: 'Too short', ... }

See also: formatErrors


toJsonSchema function

(schema: Schema<unknown>, options?: { unrepresentable?: 'throw' | 'any' }) => JsonSchema

Emit a JSON Schema (draft 2020-12) document from an s schema — for OpenAPI specs, AI structured-output constraints, editor autocomplete, cross-language contracts. Ships on the @pyreon/validate/json-schema SUBPATH so the main entry stays lean. The document describes the INPUT shape: .transform() emits its inner schema, .pipe() its source, s.preprocess() its target; .refine()/.superRefine()/.serverCheck() are runtime-only predicates and are structurally omitted. Formats map to standard format keywords (email/uri/uuid/date/date-time/time/duration); regex/startsWith/endsWith/includes map to pattern; .int() upgrades to type: 'integer'; .strict()additionalProperties: false, .catchall(s)additionalProperties: <schema>; .optional()/.nullish()/.default() fields are omitted from required.

Example

import { toJsonSchema } from '@pyreon/validate/json-schema'

toJsonSchema(s.object({ name: s.string().min(2), age: s.number().int().optional() }))
// → { $schema: 'https://json-schema.org/draft/2020-12/schema',
//     type: 'object',
//     properties: { name: { type: 'string', minLength: 2 }, age: { type: 'integer' } },
//     required: ['name'] }

Common mistakes

  • Expecting Date/BigInt/Map/Symbol/undefined kinds to emit — JSON Schema cannot express them; the emitter THROWS a [Pyreon]-prefixed error naming the kind. Pass { unrepresentable: 'any' } to emit {} (accept-anything) in their place instead.

  • Expecting the post-transform OUTPUT shape — the document describes the INPUT the schema accepts (a .transform() result type is a runtime concern JSON Schema can't express).

  • Feeding a cyclic s.lazy() schema — recursive $ref/$defs graph emission is not supported in v1; the emitter throws with guidance. Flatten the recursion or write the recursive document by hand.

  • Importing from the main entry — toJsonSchema is deliberately on the @pyreon/validate/json-schema subpath so validators-only bundles never carry the emitter.

See also: object · union


serverCheck function

(key: string, opts?: { message?: string; code?: string; key?: string; params?: Record<string, unknown>; fallback?: string }) => this

Declare a server-only validation step on a shared schema — the async/privileged tier of the client/server split (unique-email, breach-check, DNS-MX, cross-field DB lookups). On the CLIENT (no validator installed) 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. Async checks promote the parse to parseAsync, which threads an opaque context (DB handle, request) to the validator. Issue path is snapshotted at the check site, so a field/array-element check reports the correct path even though it resolves after the path unwinds.

Example

// shared schema (client + server)
const signup = s.object({
  email: s.string().email().serverCheck('email-unique', { message: 'Email already taken' }),
})

// CLIENT: cheap checks run; serverCheck is deferred
const r = signup.parse(formData)
if (r.ok && r.pending?.length) showChecking()   // 'email-unique' pending

// SERVER (registerServerCheck installed elsewhere):
const verdict = await signup.parseAsync(formData, { context: { db } })

Common mistakes

  • A client r.ok === true is NOT verification — with no validator installed serverCheck is a NO-OP that passes the value and only records a pending entry; the SERVER parseAsync is the authoritative re-validation

  • Forgetting to registerServerCheck(key, fn) on the server (e.g. not importing the server-only module) silently PASSES — an unregistered key defers to pending, it never fails

  • A registered ASYNC check promotes the parse to async, so plain .parse() bails with a [Pyreon] schema is async — use parseAsync issue — call schema.parseAsync(input, { context })

  • The DB handle / request only reaches your validator via parseAsync(input, { context }) — plain .parse() leaves ctx.context undefined

  • Import registerServerCheck only from @pyreon/validate/server (a side-effecting server-only entry) — importing it into client code drags the heavy validators into the client bundle

  • Any schema with a serverCheck anywhere in its tree skips the JIT (it can't await) and silently runs the slower interpreter path

See also: registerServerCheck · parseAsync


registerServerCheck function

(key: string, fn: (value: unknown, context?: unknown) => boolean | Promise<boolean>) => void

Register the heavy/privileged half of a .serverCheck(key) — the implementation that must NEVER reach the client bundle (DB lookups, breach-checks, MX, cross-field). Imported from @pyreon/validate/server and called from a server-only module; the matching s.…serverCheck(key) in the shared schema then validates here. Returning false fails the check with the schema's message. The second arg is the context passed to parseAsync(input, { context }).

Example

// server-only module
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))
})

See also: serverCheck


catch function

(value: T | ((input: unknown) => T)) => this

On parse FAILURE, discard the issues this schema produced and return a fallback instead of erroring — resilient parsing (Zod's .catch). The fallback is a static value or a function of the raw input. Terminal regardless of chain position: s.string().min(3).catch('x') and s.string().catch('x').min(3) behave identically. Works on both parse and parseAsync (an async transform/refine failure is caught after the Promise settles). Scoped per-schema: a caught FIELD failure is substituted while sibling failures still fail the object.

Example

s.number().catch(0).parse('nope')          // → { ok: true, value: 0 }
s.string().min(3).catch('x').parse('ab')   // → { ok: true, value: 'x' }
s.string().catch((input) => String(input)) // fallback derived from the raw input

Common mistakes

  • .catch() swallows EVERY issue this schema produced (type failures AND check/refine/transform failures alike) — a genuinely broken input is masked as ok: true; scope it narrowly, not around a whole object

  • A FUNCTION passed to .catch() is ALWAYS an input→fallback mapper (called with the raw original input), never a literal fallback — you cannot use .catch() to return a function value

  • The fallback is returned verbatim and is NOT re-validated — a fallback that doesn't satisfy the schema's type still passes through as ok: true

  • .catch() is terminal + position-independent with LAST-wins semantics — s.string().catch('a').catch('b') always yields 'b'

  • The catch fn receives the RAW original input (captured before the default/modifier prelude), not the typed or defaulted value

See also: readonly · default


readonly function

() => Schema<ShallowReadonly<T>>

Freeze the parsed output and mark it Readonly<T> at the type level (Zod's .readonly). Objects/arrays are Object.freezed (shallow) so accidental downstream mutation throws in strict mode; primitives pass through. Apply last in a chain. Uses a primitive-safe ShallowReadonly<T> (not the built-in Readonly<T>, whose Readonly<unknown> resolves to {} and breaks Schema<T>Schema<unknown> assignability).

Example

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

See also: catch


array function

() => ArraySchema<T>

Wrap this schema in an array — s.string().array()s.array(s.string()) (Zod's .array). Chains and nests (s.number().array().array()). Late-bound via a tree-shake-safe factory registry so the base class never imports the composition modules (no load-order cycle).

Example

s.string().array().parse(['a', 'b']) // → { ok: true, value: ['a', 'b'] }

See also: or · and


or function

<U>(other: Schema<U>) => UnionSchema<readonly [Schema<T>, Schema<U>]>

Union this schema with another — a.or(b)s.union(a, b) (Zod's .or). Output type is T | U.

Example

s.string().or(s.number()) // Schema<string | number>

Common mistakes

  • .or() / s.union(...) members MUST be schemas — a non-schema member (or fewer than two) throws a clear [Pyreon] error ONLY when NODE_ENV !== "production"; a production build strips the guard and a bad member crashes cryptically at parse time (member._runInto is not a function / reading _runInto of undefined)

  • A union surfaces NO per-member issues — a total miss yields one opaque invalid_union "Did not match any allowed type", so you can't tell which member was closest; members are tried in order, first-match wins

  • .or()/.and()/.array() throw COMPOSITION_UNREGISTERED if the composition factory was never registered — a bare import { string } that never references s/union/intersection skips registration

  • An async member inside a SYNC union parse pushes an async member … use parseAsync issue rather than awaiting

See also: and · array


and function

<U>(other: Schema<U>) => IntersectionSchema<T, U>

Intersect this schema with another — a.and(b)s.intersection(a, b) (Zod's .and). Output type is T & U.

Example

s.object({ a: s.string() }).and(s.object({ b: s.number() })) // { a } & { b }

See also: or · array


pipe function

<U>(target: Schema<U>) => Schema<U>

Validate with this schema, then feed the (validated, transformed) output into target (Zod's .pipe). Ideal for coerce→validate chains. Short-circuits if this schema fails; async-aware. Output type is target's.

Example

s.string().transform(Number).pipe(s.number().positive())

See also: preprocess · transform


superRefine function

(fn: (value: T, ctx: SuperRefineCtx) => void) => Schema<T>

Like .refine, but the callback may add ANY number of issues (or none) via ctx.addIssue({ message, path? }) — for cross-field validation that reports multiple problems at once. path is appended to the field's current path. Runs only if this schema passed.

Example

s.object({ pw: s.string(), confirm: s.string() }).superRefine((v, ctx) => {
  if (v.pw !== v.confirm) ctx.addIssue({ message: 'Mismatch', path: ['confirm'] })
})

Common mistakes

  • Report problems ONLY via ctx.addIssue(...) — the callback returns void, so return false or returning a message does nothing

  • It runs ONLY if the base schema produced zero issues — if the underlying type/checks already failed, your cross-field refinement never executes

  • addIssue({ path }) APPENDS to the field's current path — pass a RELATIVE path: ["confirm"], not an absolute path, or the issue lands at the wrong nested location

  • Do NOT make the callback async — the signature is sync => void and issues are collected right after it returns, so anything pushed after an await is lost

  • superRefine returns a NEW wrapper schema, not this — capture the return value

See also: refine · pipe


preprocess function

<TOut>(fn: (input: unknown) => unknown, schema: Schema<TOut>) => Schema<TOut>

Transform the raw input BEFORE schema validates it (Zod's z.preprocess) — for trim/coerce/normalize that must happen before the type-check. A standalone function (also on the s namespace), not a method.

Example

s.preprocess((v) => String(v).trim(), s.string().min(1))

Common mistakes

  • Argument order is (fn, target)fn maps the RAW input first, then target validates the mapped value; the output type is target's

  • No type flows from fn into targetfn is typed (unknown) => unknown, so nothing enforces that its return matches what target expects; that alignment is on you

  • Keep fn TOTAL (never throw) — .parse() does NOT try/catch a sync throw from fn (only parseAsync does), so a throwing preprocess propagates out of .parse()

See also: pipe · transform


nonoptional function

(message?: string) => Schema<Exclude<T, undefined>>

Reject undefined (Zod 4's .nonoptional) — re-requires a present value, e.g. after an .optional() in a reused base schema.

Example

s.string().optional().nonoptional() // rejects undefined again

Common mistakes

  • It rejects undefined at RUNTIME (pushes a "Required" issue) — it is not merely a type cast; the runtime guard is what enforces presence

  • It rejects ONLY undefined, NOT nulls.string().nullish().nonoptional() still accepts null

  • The default message is "Required"

See also: optional


stringbool function

(opts?: { truthy?: string[]; falsy?: string[]; message?: string }) => StringBoolSchema

Coerce a boolean-ish STRING to a real boolean (Zod 4's z.stringbool). Type-checks a string, then maps configured truthy/falsy tokens (case-insensitive, trimmed; defaults true/1/yes/on/y/enabledfalse/0/no/off/n/disabled) to true/false; anything else errors. Stricter than s.coerce.boolean() (which uses JS truthiness on any input).

Example

s.stringbool().parse('yes') // → { ok: true, value: true }
s.stringbool({ truthy: ['si'], falsy: ['no'] })

Common mistakes

  • The default truthy set is exactly true/1/yes/on/y/enabled and falsy false/0/no/off/n/disabled — anything else ('2', 'maybe', and crucially the EMPTY string) is a validation ERROR, not false

  • An unset env var read as '' FAILS stringbool (empty string is in neither set) — add .default(false) / .optional() when a missing var should mean false

  • It accepts ONLY strings — a real boolean or number emits a type issue; use s.coerce.boolean() (JS truthiness on any input) if you need that, they are NOT interchangeable

  • Passing truthy/falsy REPLACES the defaults, it does not extend them — stringbool({ truthy: ['yes'] }) makes '1'/'true'/'on' invalid

  • Matching is case-insensitive + trimmed, truthy checked before falsy — ' TRUE ' works, and a token in BOTH sets resolves to true

See also: coerce


never function

() => Schema<never>

Accepts NO value (Zod's z.never) — every input is a validation error, including undefined. Used for exhaustiveness and to forbid a key (s.object(...).extend({ legacy: s.never().optional() }) rejects the key only when present; a bare s.never() field is required-and-unsatisfiable).

Example

s.never().parse(1) // → { ok: false }
s.object({ a: s.string() }).extend({ legacy: s.never().optional() })

See also: unknown · custom


custom function

<T = unknown>(check?: (value: unknown) => boolean, message?: string) => Schema<T>

Escape-hatch validated by a user predicate (Zod's z.custom<T>). With NO predicate it accepts everything as T (a pure type assertion); with one it emits a custom-coded issue when the predicate returns false. The output type is the caller-supplied T — never narrowed, since the predicate is opaque.

Example

s.custom<`${number}px`>((v) => typeof v === 'string' && v.endsWith('px'))
s.custom<MyType>() // accept anything as MyType

See also: instanceof · refine


instanceof function

<T>(ctor: new (...args: any[]) => T, message?: string) => Schema<T>

Asserts input instanceof Ctor (Zod's z.instanceof). The canonical way to validate runtime class instances — s.instanceof(File), s.instanceof(Date), s.instanceof(URL), user classes. The default message names the class; pass a second arg to override.

Example

s.instanceof(File) // validate an uploaded File
s.instanceof(Date, 'need a Date')

Common mistakes

  • It uses native input instanceof ctor, so CROSS-REALM instances FAIL — a Date/File/URL from an iframe, worker, or vm has a different constructor identity and won't validate

  • It cannot survive an SSR→client (or any JSON) boundary — a hydrated plain object / date string is not an instance, so instanceof(Date) rejects deserialized data; validate the serialized shape (an ISO string) and reconstruct instead

  • The output is the input unchanged — instanceof ASSERTS, it does not construct or coerce an instance

See also: custom


nativeEnum function

<E extends Record<string, string | number>>(enumObject: E) => Schema<E[keyof E]>

Validate a VALUE of a TS native enum (or a const value-object) — Zod's z.nativeEnum. Output type is the enum's value union (E[keyof E]). Correctly filters out the numeric reverse-mappings TS auto-generates (a numeric enum { A } compiles to { A: 0, 0: 'A' }, so 'A' is NOT accepted as input — only 0 is). Use s.enum([...]) instead for a plain literal array.

Example

enum Role { Admin = 'admin', User = 'user' }
s.nativeEnum(Role).parse('admin') // → { ok: true, value: 'admin' }

See also: enum · literal


Package-level notes

Standard Schema is parse-only: The protocol deliberately omits a metadata channel — that's the gap withField fills. The protocol also doesn't carry i18n keys — formatErrors adds that layer.

withField mutates in place: ArkType's Type instances are callable functions whose ~standard.validate does this(input)this must be the callable schema itself. An Object.create() clone is not callable and breaks ArkType. Symbol-keyed non-enumerable mutation is invisible to JSON / for…in / Object.keys / library-internal comparators. Safe.

Ships its own validator AND interops with others: v1 ships Pyreon's own s validator runtime (chainable + function-comp, Standard Schema-native, opt-in by import). The DX helpers (withField / parseReactive / formatErrors) ALSO work on top of any other Standard Schema validator (Zod / Valibot / ArkType / typia) — backward-compatible. Use whichever; mix freely.

serverCheck is client-no-op, server-async: .serverCheck(key) only runs where its validator is installed. On the client it ALWAYS passes (recorded on Result.pending) — the SERVER is the authoritative re-validation; never treat a client ok: true with pending as fully verified. A registered ASYNC check promotes the parse to a Promise, so parse() returns a parseAsync-directing issue — use parseAsync(input, { context }) server-side. serverCheck fields JIT-compile like everything else (the JIT defers async-resolving subtrees onto a pending list its root return awaits) — no perf cliff.

Three shipped performance layers — runtime JIT + two build flags: The runtime JIT (core/jit.ts) auto-applies on first .parse() and is differential-fuzz-locked against the interpreter (incl. async trees + the partial-inline seam). On top: pyreon({ optimizeValidators: true }) rewrites module-level s. chains to the tree-shakeable /mini form (~−41% measured), and pyreon({ compileValidators: true }) attaches build-emitted monomorphic .is() verdicts (1.6–3× on hot verdict loops — nanoseconds; only matters in tight .is() loops, .parse() is unchanged). Both build flags recognize statically-analyzable chains only (a dynamically-built or out-of-emit-scope chain — e.g. .cuid2(), unions — gracefully stays full-runtime).

Pyreon validator + Standard Schema DX — API Reference