@pyreon/validate
v0.50.0
Published
Pyreon DX layer over Standard Schema — withField metadata, reactive parse, i18n-aware error formatting. Works with Zod, Valibot, ArkType, or any Standard Schema-compliant validator.
Downloads
2,219
Readme
@pyreon/validate
Pyreon's validator + Standard Schema DX layer — field metadata, reactive parse, i18n-aware error formatting, and (since v1) Pyreon's own validator runtime.
Standard Schema (https://standardschema.dev) is the cross-library protocol implemented natively by Zod 3.24+, Valibot 1.0+, ArkType 2.0+, and any future spec-compliant validator. @pyreon/validate adds the things the spec deliberately omits, plus Pyreon-native bridges to @pyreon/reactivity and @pyreon/i18n.
Two ways to use it:
- DX helpers on top of your validator of choice (Zod / Valibot / ArkType). The Pyreon validator runtime tree-shakes away entirely — a DX-helpers-only import is ~0.5KB gz (measured).
- Pyreon's own
svalidator (v1) — chainable + function-comp hybrid, Standard Schema-native, ~3.5KB gz when imported. No third-party validator needed.
bun add @pyreon/validate
# Option 1 — pair the DX helpers with a third-party validator:
bun add zod # or
bun add valibot # or
bun add arktype
# Option 2 — use Pyreon's own `s` validator (no extra dep):
# import { s } from '@pyreon/validate'Quick start
import { z } from 'zod'
import { signal } from '@pyreon/reactivity'
import { useI18n } from '@pyreon/i18n'
import { withField, parseReactive, formatErrors, watchValid } from '@pyreon/validate'
// 1. Attach metadata.
const emailSchema = withField(z.string().email(), {
label: 'Email address',
placeholder: '[email protected]',
hint: 'We never share your email',
i18nLabel: 'auth.email.label',
})
// 2. Reactively validate as the user types.
const $email = signal('')
const $result = parseReactive(emailSchema, $email)
// 3. Format errors with i18n.
const { t } = useI18n()
effect(() => {
const r = $result()
if (r.issues) showErrors(formatErrors(r.issues, t))
})
// 4. Subscribe only to validity flips.
const stop = watchValid(emailSchema, $email, (valid) => {
submitButton.disabled = !valid
})API surface (v1)
| Helper | Purpose |
| --- | --- |
| withField(schema, meta) | Attach FieldMeta (label, hint, placeholder, i18n keys, autoFocus, autoComplete) to any Standard Schema. Returns the same reference — metadata is a Symbol-keyed non-enumerable slot. |
| getMeta(schema) | Read attached metadata back. Returns undefined for unwrapped schemas. |
| resolveMetaField(schema, field, t?) | Read a field through optional i18n. t('auth.email.label') wins over meta.label when it resolves. |
| parseReactive(schema, source) | Computed<ParseResult> that re-derives on signal changes. Synchronous. |
| parseReactiveAsync(schema, source) | Async variant for schemas with async refinements. Stale results are superseded automatically — an awaited stale frame resolves to the LATEST run's result, so rapid source changes can never deliver a stale verdict. |
| watchValid(schema, source, cb) | Fire cb(valid) only on validity transitions, not every error change. |
| formatError(issue, t?) | Resolve a single issue's text. issue.key + t wins; falls back to fallback then message. |
| formatErrors(issues, t?) | Array variant. |
| formatErrorsByPath(issues, t?, options?) | Build a per-field error map keyed by the issue's path. Compatible with @pyreon/form's Errors shape. |
| toJsonSchema(schema, opts?) | Emit a JSON Schema (draft 2020-12) document from an s schema — from the @pyreon/validate/json-schema subpath so the main entry stays lean. Unrepresentable kinds (date/bigint/map/…) throw by default, or emit {} with { unrepresentable: 'any' }. |
Client / server validation
One shared schema, a thin client and a heavy server — without shipping the heavy code to the browser.
Format upgrade (automatic). The lightweight in-bundle validators (s.string().email(), .phone()) are upgraded to strict server validators the instant the server imports @pyreon/validate/server — strict RFC-5322 email + 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..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' pending
// SERVER-only module — register the heavy impl:
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 } })Notes:
- The server is authoritative — never treat a client
ok: truewithpendingentries as fully verified. - An async registered check promotes the parse to a Promise, so
parse()returns a parseAsync-directing issue — useparseAsync(input, { context })server-side. - Object fields and array elements are validated async-aware, so the issue
pathis correct even though an async check resolves after the path unwinds. serverCheckfields JIT-compile like everything else — the JIT defers an async-resolving subtree onto a pending list its root return awaits (locked by the JIT ↔ interpreter async differential fuzz), so there is no perf cliff for schemas that carry server checks.
String format checks (s.string())
email (3 precision tiers) · url · uuid · ip · cidr · phone · e164 · creditCard · cuid · cuid2 · ulid · nanoid · emoji · base64 · base64url · jwt · duration · .iso.date() / .iso.dateTime() / .iso.time() — plus regex / startsWith / endsWith / includes.
The hot email() 'standard' tier runs a table-driven charcode scanner (~1.6× the Zod-parity regex, byte-identical verdict — locked by an exhaustive + fuzz differential against the published EMAIL_RE).
Every format routes through the client/server registry seam — a server can swap in a stricter validator for any of them in place via installFormatValidator(name, fn) (the same mechanism @pyreon/validate/server uses to upgrade email/phone), without touching the shared schema.
import { s } from '@pyreon/validate'
s.string().cuid2().parse('tz4a98xxat96iws9zmbrgj3a') // ok
s.string().ulid().parse('01ARZ3NDEKTSV4RRFFQ69G5FAV') // ok
s.string().jwt() // header.payload.signature shape
s.string().base64().min(4) // composes with length checksSchema methods (s runtime)
| Method | Purpose |
| --- | --- |
| .catch(fallback) | Resilient parse — on failure, discard issues and return a static or input-derived fallback. Terminal regardless of chain position; works on parse + parseAsync; scoped per-schema (a caught field failure substitutes while sibling failures still fail the object). |
| .readonly() | Object.freeze the parsed output (shallow) + Readonly<T> at the type level. Apply last. |
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' }
const cfg = s.object({ port: s.number() }).readonly().parse({ port: 80 })
// cfg.value is Readonly<{ port: number }> and frozenTree-shaking — keep chaining, let the compiler do it
The chainable s. API can't tree-shake its checks (s.string() carries every format method on its prototype, so any schema pulls all 17 string-format regexes — chaining fundamentally requires the methods to exist). So the win comes from a build-time rewrite, not a second API to learn: opt into
// vite.config.ts
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 const X = s.<chain> into a lean, tree-shakeable form that imports only the checks it uses — so the bundle prunes the rest. Verdict-for-verdict identical to the runtime (parity-locked end to end). Measured (Vite/Rollup, published bundle): a 3-field schema drops ~11 KB → ~6.5 KB gz (−41%). Conservative: a dynamically-built schema (in a function, conditionally, non-literal arg) or a .tsx schema gracefully stays full-runtime.
Under the hood the rewrite lowers to @pyreon/validate/mini — lean constructors + standalone .check() actions. That's the compiler's emit target, not the headline API, but it's importable directly as an escape hatch for dynamic schemas / non-Vite bundlers:
import { object, string, minLength, email, pipe } from '@pyreon/validate/mini'
const Login = object({ name: string().check(minLength(2)), email: string().check(email()) })JSON Schema emit
toJsonSchema(schema) (from the @pyreon/validate/json-schema subpath) walks the introspectable schema graph and emits a JSON Schema draft 2020-12 document — for OpenAPI specs, AI structured-output constraints, editor autocomplete, cross-language contracts.
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 contract, precisely: 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; unrepresentable kinds (s.date(), s.bigint(), s.map(), s.undefined(), …) throw by default — pass { unrepresentable: 'any' } to emit {} in their place (Zod 4's policy split). Cyclic s.lazy() schemas throw (no $defs/$ref graph in v1 — documented scope).
Why mutate-in-place?
withField() mutates the original schema with a Symbol-keyed non-enumerable property. It does NOT clone.
ArkType's Type instances are callable functions whose ~standard.validate does this(input) — this must be the callable schema itself. A shallow clone (Object.create()) is not callable and breaks that contract. Symbol-keyed non-enumerable mutation is invisible to:
JSON.stringify(skips symbol keys)for…in/Object.keys/Object.entries- Structured clone
- Library-internal schema comparators
…so the mutation is functionally hidden. Re-wrapping is the natural extension — withField(base, { a }) then withField(base, { b }) produces a schema with both a and b automatically.
What this is NOT
- Lock-in. The DX helpers work on top of any Standard Schema validator (Zod / Valibot / ArkType / typia) — use Pyreon's own
sruntime or bring your own; mix freely. ExistingzodSchema/valibotSchema/arktypeSchemaadapters from@pyreon/validationcontinue to work. - Slow-by-architecture. The
sruntime JIT-compiles pure object/array/primitive trees to a single flat validator on first parse (core/jit.ts, differential-fuzz-locked against the interpreter — including async trees) — it wins or ties every benchmark row except flat-object valid-parse, where ArkType's alias-the-input design is ~1.2× ahead of Pyreon's immutable stripped-clone output (a deliberate semantic, not a gap; see the bench header). On top of that, two BUILD-time levers ship in@pyreon/vite-plugin:optimizeValidators(tree-shaking rewrite, above) andcompileValidators(build-emitted monomorphic.is()verdicts — 1.6–3× on hot verdict loops;@pyreon/compiler:analyzeValidaterecognizes statically-analyzables.chains only, so dynamically-built schemas gracefully stay full-runtime).
See also
- Standard Schema spec
@pyreon/validation— per-lib adapters for@pyreon/form@pyreon/form— signal-based forms@pyreon/i18n— translation provider
