Validation and HTTP input
Validate request bodies and queries with bounded Fetch-compatible adapters.
Define the schema once#
import { object, string } from '@zeruxjs/validator/schemas';
import { validatedJsonHandler } from '@zeruxjs/validator/http';
const schema = object({ name: string() });
const handler = validatedJsonHandler(schema, async body => ({ hello: body.name }));
// The host mounts this handler explicitly and awaits handler.close() on teardown.Schema constructors and synchronous/asynchronous parsing actions are exported from /schemas and /browser; the root remains compatible. /browser also provides explicit DOM feedback and excludes request parsing. Choose a schema that represents the operation's allowed input, not an entire database object with private fields.
Framework registration#
Pass the handler to registerFetchRoutes from zeruxjs/fetch-route. The bridge preserves its HTTP response contract and avoids reading a body twice. Normal Zerux route context.body is already parsed; do not pass an already-consumed Node stream to a second request parser.
Input bounds#
readJsonBody defaults to a 1 MiB cap and a 3000 ms absolute body deadline. It checks media type, UTF-8, malformed JSON and actual/declarative length. Shared adapters default to 32 reader slots. parseJsonRequest and parseQuery apply schemas. Configure maxBytes/maxQueryFields for the endpoint's budget. Schema execution has per-parse depth, work, collection, issue and async-refinement limits. Trusted callbacks still own deadlines and any tasks they start internally.
Errors and authorization#
HttpValidationError.toResponse() gives bounded details and no-store headers. Do not disclose rejected secrets or complete raw inputs. Validation is not authorization: verify identity, tenant ownership and write permission before committing data.
Install and select the integration#
npm install @zeruxjs/validator@0.0.4There is no automatic validator startup service in zerux.config.js for this package. Import its public APIs and create the object or browser controller in the application owner that needs it. If a config object holds custom settings, your plugin/application code must explicitly consume them. See configuration integration boundaries and TypeScript.
A practical integration workflow#
Define a schema at the HTTP boundary and parse unknown input before application work. Use the Fetch adapters for streamed JSON/query limits; keep Node IncomingMessage and Web Request shapes separate. Prefer validated output inference over an as cast, and use safe-parse when validation failure is an expected branch rather than a process error.
TypeScript: inputs, results and ownership#
import { parseJsonRequest } from '@zeruxjs/validator/http';
type Inputs = Parameters<typeof parseJsonRequest>;
type Result = Awaited<ReturnType<typeof parseJsonRequest>>;Inputs is the function's complete argument tuple, including optional parameters. Result removes a Promise when the function is asynchronous. These aliases follow the installed declarations; no internal src/dist import is required. For a generic factory, keep the concrete call's inferred type when an application needs a narrower result than ReturnType preserves. Use satisfies or a public option type for configuration literals, and validate unknown request/file data separately.
In a JavaScript application the equivalent editor annotation is /** @type {Parameters<typeof parseJsonRequest>} */ on a matching argument tuple. This provides diagnostics only when JavaScript checking is enabled. It does not validate network data or create a resource automatically. See strict application setup for the complete NodeNext checker configuration.
Failure modes and resource lifetime#
The default JSON reader is bounded to 1 MiB; use explicit byte/query-field caps for application endpoints. Invalid media type, malformed JSON, oversized content and schema issues are different failures. Avoid returning raw secrets or complete submitted values in public error details. An inferred output type does not establish authentication, authorization or database constraints.
Verify before deploying#
- Exercise the example with the actual owning runtime/browser, not only an imported module.
- Test one invalid input and the relevant unavailable/denied dependency path; do not turn those into a silent success.
- Confirm teardown leaves no owned listeners, timers, connections or stale per-user state.
- Check the production entry point and installed export map; a workspace checkout can contain features not yet published to npm.
Public option and data contracts#
These tables list fields declared directly on the named public contract. Referenced/inherited types retain their own declarations; optional does not imply a default value. Use the lifecycle and configuration chapters above for actual initialization behavior.
HttpValidationOptions#
| Field | Accepted type/value | Optional |
|---|---|---|
maxBytes | number | Yes |
maxQueryFields | number | Yes |
maxQueryLength | number | Yes |
maxFields | number | Yes |
maxFieldBytes | number | Yes |
bodyTimeoutMs | number | Yes |
maxConcurrentReads | number | Yes |
signal | AbortSignal | Yes |
validation | Omit<Config<BaseIssue<unknown>>, "signal"> | Yes |
ToJsonSchemaOptions#
| Field | Accepted type/value | Optional |
|---|---|---|
strict | boolean | undefined | Yes |
ParseBooleanConfig#
Parse boolean config interface.
| Field | Accepted type/value | Optional |
|---|---|---|
truthy | MaybeReadonly<unknown[]> | undefined | Yes |
falsy | MaybeReadonly<unknown[]> | undefined | Yes |
ParseJsonConfig#
Parse JSON config interface.
| Field | Accepted type/value | Optional |
|---|---|---|
reviver | (this: any, key: string, value: any) => any | Yes |
StringifyJsonConfig#
Stringify JSON config interface.
| Field | Accepted type/value | Optional |
|---|---|---|
replacer | ((this: any, key: string, value: any) => any) | (number | string)[] | Yes |
space | string | number | Yes |
CacheConfig#
Cache config type.
| Field | Accepted type/value | Optional |
|---|---|---|
maxSize | number | Yes |
maxAge | number | Yes |
Config#
Config interface.
| Field | Accepted type/value | Optional |
|---|---|---|
lang | string | undefined | Yes |
message | ErrorMessage<TIssue> | undefined | Yes |
abortEarly | boolean | undefined | Yes |
abortPipeEarly | boolean | undefined | Yes |
Functional inference and explicit compatibility#
import { object, string, number, optional, pipe, trim, transform, safeParse,
type InferInput, type InferOutput } from '@zeruxjs/validator';
const schema = object({
length: pipe(string(), trim(), transform(value => value.length)),
count: optional(number(), 1)
});
type Input = InferInput<typeof schema>;
type Output = InferOutput<typeof schema>;
const result = safeParse(schema, { length: ' Ada ' }, { maxIssues: 20 });
if (result.success) console.log(result.output); // {length:3,count:1}Functional pipes preserve separate input/output types through objects, arrays and unions. Public types live in validator. Zero-argument constructor action shorthand also preserves output inference; other legacy shorthand and z/chain methods can retain broad types. Use functional composition for precise inference.
| Surface | Implemented contract |
|---|---|
| Functional schemas/actions/parsers | Existing named schema subset, typed pipelines and sync/async parses |
| Object keys | object strips; looseObject preserves safe extra keys; strictObject rejects; objectWithRest validates extras |
z / chain | Limited convenience API, not full Zod compatibility |
| Valibot-style names / Standard Schema | Existing subset/adapter, not full Valibot conformance |
| JSON Schema / ajvSafeParse | Limited local conversion/checking; not the AJV engine or full JSON Schema |
Object schemas reject arrays, read declared fields only from own properties and reject unsafe schema keys. Class instances remain eligible by their own fields. Missing optional fields stay absent unless defaulted; explicit undefined remains present. optional accepts undefined, nullable accepts null, nullish accepts both. A default factory runs once for a missing field, before wrapped validation and transforms. Defaults are validated; a sentinel returned again short-circuits. Pipelines execute left to right. Refinements see earlier transforms; transforms stop after issues, while further typed refinements can collect errors unless abort flags stop them. Callback exceptions propagate. Unions try branches in order and return the first successful output, or structured branch issues on failure. Keep callbacks side-effect-free because failed union branches may run first.
undefinedable permits an explicit undefined value without declaring the object key optional. exactOptional delegates supplied values to the wrapped schema and retains default metadata; its run method does not apply a default replacement. Do not treat that metadata as evidence that an absent field will be filled.
Parsed structural containers are new, but leaves, loose extras, dates/instances, custom/unknown values and cache outputs may share identity. Parsing is not a deep clone and transforms can mutate their inputs.
Collections, callbacks and measurement units#
Map/Set schemas allocate new native collections but can retain borrowed child outputs. Transformed duplicate Map keys keep the last assignment; Set values collapse under native equality. Async collection work starts before ordered result accumulation, so abortEarly does not prevent every child from starting. Record input-key filtering does not revalidate trusted transformed output names.
Custom predicates assert the host-declared generic type; they do not prove that type independently. Lazy getters select trusted child schemas per parse without memoization or source-text evaluation. Intersection merging can mutate its first branch result before a later conflict and has no independent cycle/depth budget or rollback; host transforms must produce compatible finite output graphs.
Length actions measure UTF-16 code units or array-like positions. Size actions measure Blob bytes or Map/Set element counts. These refinements are separate from streaming HTTP admission, grapheme counts and root execution budgets. Their legacy numeric threshold constructors do not independently validate host configuration. Native remainder checks preserve distinct number/bigint arithmetic; bigint zero divisors and mixed numeric kinds throw, and floating-point values are not rounded.
Parse budgets, cancellation and private notifications#
| Root parse option | Default | Maximum |
|---|---|---|
| maxDepth | 64 | 128 |
| maxNodes | 10000 | 100000 |
| maxCollectionSize | 1000 | 10000 |
| maxIssues | 100 | 1000 |
| maxConcurrency | 8 | 32 |
Limits are positive integers. Depth covers input nesting and schema/action nesting (wrappers count); work bounds input scan nodes and schema/callback steps separately. Collection limits cover arrays, maps, sets, object own keys and object schema fields before unknown-key removal. Strings use maxLength/HTTP byte limits. An iterative scan rejects cycles and enumerable accessors, even below unknown/any. Issue counts include speculative union branches. Exhaustion throws ValidationLimitError with code/limit even for safeParse; partial success is never returned. These bounds do not sandbox trusted callbacks, returned functions or schema-conversion tools. Async default/lazy factories own their resource budgets.
abortEarly collects the first container failure and abortPipeEarly controls pipe collection. Async siblings can already be running; fail-fast output does not undo started callbacks. Built-in issues add code (kind.type) and retain full paths and nested union issues. Raw issue input/messages are private application data. HTTP adapters return generic messages/codes/bounded paths, map budget exhaustion to 413 and propagate operational errors. JSON validation observes Request.signal. Validation remains separate from authentication, tenant ownership and consent.
import { string, pipeAsync, checkAsync, safeParseAsync } from '@zeruxjs/validator';
const schema = pipeAsync(string(), checkAsync(async (value, {signal}) => {
signal?.throwIfAborted();
return value.length > 0;
}));
await safeParseAsync(schema, 'Ada', {
maxConcurrency: 2,
onError: event => console.log(event.failure, event.issues)
});checkAsync, partialCheckAsync, customAsync and transformAsync receive context.signal as their second callback argument; checkItemsAsync receives context fourth. Raw async actions have config.signal. Nested async schemas/actions reject during sync parse before invocation. Native async functions misused as sync checks/transforms, defaults or raw callbacks also reject; thenables returned by other sync callbacks are rejected/observed after the callback returns.
Concurrency bounds built-in async refinement/transform callbacks per parse, including checkItemsAsync's inner callbacks. Cancellation rejects with AbortError and preserves the external reason in cause. Failure/cancellation stops queued work, drains started work and removes its root listener. Validators must cooperate and settle: no wall-clock timeout can force arbitrary JavaScript to stop. No background task or external service starts on import.
Hooks are synchronous and parse-local. They receive only success/failure class, codes and redacted paths (only array indexes are retained), never submitted values, output, custom messages or exception text. Hook exceptions/thenables produce ValidationHookError after cleanup; failed success hooks do not invoke error hooks. Invalid configuration rejects before hook notification. Legacy global language, abort flags and message registries remain; hooks/signals are never retained in global config. Root execution controls reject in config(schema,...). Explicit signal/limit overrides bypass caches; default cache behavior is preserved.
The package-local checks compile the public-import example/inference against a real offline npm-installed archive and exercise nested paths, unions, defaults, limits, async misuse, concurrency, dependency failures, hook exceptions and cleanup. Installed shared tooling/TypeScript are required. There are no live-service prerequisites, and no live-provider/browser behavior is claimed.
Message and metadata ownership#
Message registries use exact language keys, including a distinct undefined key; they provide no locale fallback, expiry, cardinality cap or tenant isolation. Configure trusted static messages at bootstrap. Getters return borrowed messages without invoking them; validation may invoke message callbacks with private issue data. Deleting a specific message leaves its constructor bucket allocated.
getDefaults/getFallbacks recursively read declared object entries and tuple positions, not dynamic rest values. They may invoke trusted leaf factories without an input dataset or root parse budget. Async variants start sibling factories concurrently; rejection does not cancel independent factory work. These helpers do not validate replacements or deep-clone their results.
getTitle/getDescription prefer the last matching metadata at the highest pipeline level. getMetadata instead shallowly merges in depth-first source order, with later keys winning. Metadata is host-owned, unvalidated and unescaped; reading record getters may execute host code. Escape text for its eventual output context and keep untrusted records outside this API.
Local compatibility boundaries#
Format/checksum matches do not establish mailbox/card/device ownership, JWS signatures, digest integrity, calendar existence or destination authorization. Native URL parsing accepts schemes the host may need to deny. Custom RegExp and input-size policy remain explicit because native matching cannot be force-stopped by a cooperative deadline.
Native array callback conveniences do not await callbacks; sortItems mutates its array. Readonly/brand/flavor are type-level markers, not runtime freezing or permissions. Function wrappers retain configuration and do not preserve a method receiver; create them with parseAsync when their child schemas are async. Return validation cannot roll back effects of a function that already executed.
The local JSON Schema checker is not AJV and does not run original transforms. It ignores unsupported keywords, retains inherited-key presence/slash-path behavior and has no independent cycle/work/deadline budget. Converter strict mode rejects only explicitly unsupported branches; it does not certify full coverage. Use the functional APIs for their documented own-field and root-budget behavior.
Browser schemas and app-owned HTTP adapters#
The /schemas entry exposes the pure API without HTTP request parsing. /browser adds createFormFeedback and DOM types. /http exposes Fetch adapters; the root retains compatibility exports. Browser imports start no validation, listeners or server processes and do not depend on Node globals. Use the browser DOM library for /browser declarations; these APIs do not consume Node IncomingMessage.
import { strictObject, string } from '@zeruxjs/validator/schemas';
import { createHttpInputAdapter } from '@zeruxjs/validator/http';
const input = createHttpInputAdapter({
maxBytes: 65536, bodyTimeoutMs: 3000, maxConcurrentReads: 8,
duplicates: 'reject'
});
try {
const value = await input.parseQuery(
strictObject({ name: string() }), 'https://example.test/?name=Ada'
);
console.log(value.name);
} finally { await input.close(); }Reuse one adapter per deliberate application owner to share body reader slots. Standalone helper calls each own a scope and cannot share concurrency limits. validatedJsonHandler owns a shared adapter and adds handler.close() for shutdown. close is idempotent, prevents new operations, aborts owned work and awaits cleanup. Schema/provider exceptions propagate; cancellation of noncooperative validation can still delay close. Do not share tenant/user-specific mutable callback state.
| HTTP budget | Default | Allowed range |
|---|---|---|
| maxBytes | 1 MiB | 1 byte–16 MiB |
| bodyTimeoutMs | 3000 ms | 10–60000 ms |
| maxConcurrentReads | 32 | 1–1024 |
| maxFields | 200 | 1–1000 |
| maxQueryFields | maxFields | 1–1000 |
| maxQueryLength | 16384 characters | 1–1048576 |
| maxFieldBytes | 16384 UTF-8 bytes | 1–1048576 |
All budgets are positive safe integers. JSON key inspection additionally caps 10000 nodes. validation supplies existing parse controls/hooks, excluding signal; Request.signal and adapter signal both propagate. Body deadlines do not reset with trickled bytes. They cover the read and asynchronous decoding, not arbitrary schema/application work. Synchronous parsers cannot be preempted; byte caps bound their input. Bytes are counted before decoding, and Content-Length must match. On owned failures the reader is cancelled and its lock, slot, timer and listener released. Cancellation rejection remains in the internal cause; an uncooperative stream cancel promise does not hold a slot indefinitely. A body locked by another consumer is rejected without stealing that lock.
JSON accepts application/json and application/*+json. Forms accept URL-encoded or multipart/form-data with a valid boundary; UTF-8 is required and compressed input is rejected. Multipart uses the runtime's Fetch parser after the bounded read; field caps apply after native decoding, not as a streaming multipart parser. File fields reject unless files:'preserve' is explicit, then bounded File objects are passed to schemas without reading contents for callbacks or storing uploads. Host upload policies remain separate. See the Fetch Standard and Streams Standard for platform contracts.
Query, header and form field policies#
parseQuery, parseHeaders, parseFormData and parseFormRequest collect fields into null-prototype dictionaries before schema validation. Missing fields stay absent. object strips unknown keys, strictObject rejects them, looseObject preserves safe keys and objectWithRest validates them. Reserved prototype names, including reserved dot/bracket segments, reject before stripping. Keys are literal and no nested expansion occurs. Names are nonempty, control-free, at most 256 characters; headers also require token syntax and names are lowercased.
| Field option | Contract |
|---|---|
| duplicates:'array' (default) | Singletons scalar, repeated names arrays |
| duplicates:'reject' | Reject any repeat, including empty values |
| duplicates:'first'/'last' | Choose literal first/last before empty handling |
| empty:'preserve' (default) | Keep empty strings |
| empty:'omit' | Remove selected empties; all removed means absent/default-eligible |
| coerce:{name:'number'} | Strict finite JSON-number spelling; no blanks, hex, Infinity or NaN |
| coerce:{name:'boolean'} | Exact lowercase true/false |
| coerce:{name:'string'} | Keep strings; do not stringify files |
| files:'reject' (default)/'preserve' | Explicit bounded File acceptance |
Selected repeated values coerce individually. Arrays stay arrays after empty filtering. Coercion does not create absent fields and no schema-based coercion or singleton-to-array guessing occurs. JSON keeps its native types. URL percent decoding follows URLSearchParams. Total name/value UTF-8 bytes plus preserved File sizes must fit maxBytes, including already-decoded inputs. Header coercion rules use lowercase names. Native Headers may have already merged duplicate wire values; commas are not split, and lost duplicates cannot be reconstructed. Supply original pairs when exact duplicate detection is required.
Safe errors and explicit accessible feedback#
Client statuses are 400 for malformed/disconnected input, 408 deadline, 413 budgets, 415 media type, 422 schema rejection and 429 reader capacity. HttpValidationError retains internal message/cause, but toResponse emits fixed generic messages, no-store headers and at most 20 sanitized issues. Only declared schema field names and numeric indexes remain in paths; dynamic record/map keys are redacted. Custom messages, passwords, file contents and error causes are never serialized. Never serialize the Error object yourself. Operational provider/schema errors remain exceptions rather than false validation success.
import { createFormFeedback } from '@zeruxjs/validator/browser';
const fields = new Map([['name', {control: nameInput, description: nameError}]]);
const feedback = createFormFeedback({ resolveField: path => fields.get(path) });
feedback.update([{path:'name',code:'schema.string',message:'Invalid input'}]);
// Clear on success; destroy on host component/page teardown:
feedback.destroy();nameInput/nameError are host-supplied elements; the description must have a stable whitespace-free ID. Feedback sets aria-invalid, merges aria-describedby and writes generic textContent. It never reads input values or files. Optional synchronous onIssue receives only a sanitized code. Thrown errors/thenables roll back changes and propagate. clear/destroy restore prior attributes/text; destroy is idempotent. The host owns summaries, focus, announcements, submission and unresolved/root errors. No user-controlled selector interpolation or frontend framework is added.
Focused checks cover Node Fetch streams, reader cleanup, slow/trickled/oversized/ disconnected bodies, duplicate/coercion/prototype policies, sanitized causes, multipart/file handling, a DOM feedback fixture and browser bundle dependencies. Installed archive consumers verify public subpaths and declarations. Browser checks trace actual imports and parse emitted executable identifiers/module references; documentation text is not mistaken for an executed Node API. No live browser/proxy/provider behavior is implied. No credentials are required; a real browser and deployment-specific Fetch bridge/proxy remain integration checks.
Public entry points
Manifest reference for @zeruxjs/validator@0.0.4. Each target describes a published entry, not automatic framework registration.
| Import | Target |
|---|---|
@zeruxjs/validator | {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
} |
@zeruxjs/validator/http | {
"types": "./dist/integrations/http.d.ts",
"import": "./dist/integrations/http.js"
} |
@zeruxjs/validator/browser | {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
} |
@zeruxjs/validator/schemas | {
"types": "./dist/core.d.ts",
"import": "./dist/core.js"
} |
Runtime requirements and dependencies
{
"engines": {},
"dependencies": {},
"peerDependencies": {},
"bin": {}
}Full package README
@zeruxjs/validator#
Schema validation and transformation utilities with synchronous and asynchronous parsers. The library exports its schema constructors and validation actions from the package root.
Fetch-compatible HTTP adapter#
import { object, string } from '@zeruxjs/validator/schemas';
import { validatedJsonHandler } from '@zeruxjs/validator/http';
const schema = object({ name: string() });
const handler = validatedJsonHandler(schema, async (body) => ({ hello: body.name }));
try {
const response = await handler(new Request('https://example.test/api', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Maya' })
}));
console.log(await response.json());
} finally {
await handler.close();
}readJsonBody streams up to 1 MiB by default, rejecting invalid media types, malformed JSON and oversized payloads; parseJsonRequest and parseQuery validate with both synchronous and asynchronous schemas. HttpValidationError.toResponse() emits bounded error details and Cache-Control: no-store. Do not disclose raw input in external API errors. Use maxBytes/maxQueryFields to set explicit caps. Body reads also have an absolute deadline and app-owned concurrency limit (below). This adapter works with the Web Fetch Request API and can be used by ZeruxJS route handlers or other framework integrations.
Functional schemas and inference#
import { object, optional, string, number, pipe, trim, transform, safeParse,
type InferInput, type InferOutput } from '@zeruxjs/validator';
const schema = object({
length: pipe(string(), trim(), transform(value => value.length)),
count: optional(number(), 1)
});
type Input = InferInput<typeof schema>; // length: string; count?: number
type Output = InferOutput<typeof schema>; // length: number; count: number
const result = safeParse(schema, { length: ' Ada ' }, { maxIssues: 20 });
if (result.success) console.log(result.output); // {length:3, count:1}Use pipe/pipeAsync for precise input/output inference through transforms, objects, arrays and unions. Zero-argument constructor action shorthand, such as string([transform((value:string) => value.length)]), also preserves output inference. Some legacy argument-taking shorthand and z/chain methods retain broad types; use functional composition when correctness depends on inference. Public types remain owned by this package, not by @zeruxjs/typescript.
Object, union and default contracts#
| Schema | Unknown own keys |
|---|---|
object / objectAsync | Removed |
looseObject / async equivalent | Preserved, except unsafe prototype keys |
strictObject / async equivalent | Rejected |
objectWithRest / async equivalent | Validated with the rest schema |
Object schemas require a non-null object that is not an array. Declared fields read only own properties; inherited values cannot satisfy required fields. Unsafe schema keys (__proto__, constructor, prototype) are explicitly rejected by the synchronous object implementations. Async schema dictionaries remain trusted host configuration; the root input scan separately guards unsafe candidate keys. Collection limits apply before key stripping, including unknown keys. Non-array class instances can still be accepted by their own fields; this is not a plain-record guarantee.
optional accepts undefined, nullable accepts null, and nullish accepts both. A default replaces the wrapper's matching sentinel before the wrapped schema runs. Missing optional fields without defaults stay absent; explicitly supplied undefined stays an own property. A missing field's default factory runs once. Defaults are validated/transformed by the wrapped schema. For async defaults use the async wrapper and async parse. A default that returns the sentinel again short-circuits to that sentinel instead of repeatedly invoking the factory.
undefinedable accepts an explicit undefined value but does not mark an object key optional. exactOptional marks presence metadata while delegating supplied values to the wrapped schema; its stored default is currently introspection metadata, not a replacement branch in its run method. Use the implemented optional/nullish default handling when a missing value must be filled rather than relying on that metadata alone.
Pipelines run left to right: wrapper defaults, wrapped schema, then actions in listed order. A transform before a refinement changes the value that refinement sees. A transform after any issue does not execute. Further refinements can collect issues while the dataset remains typed, unless abortEarly/abortPipeEarly stop them. Callback exceptions are operational failures and propagate, rather than becoming an ordinary validation rejection or being replaced by fallback values.
Unions try branches in order and return the first issue-free output. If only one branch has a typed-but-invalid result, its issues are returned; otherwise the union issue contains nested branch issues. Failed branches may execute callbacks before a later branch succeeds; keep refinements side-effect-free.
Objects/arrays/tuples and mapped collections create new structural containers. This is not a deep clone: unknown/custom leaves, loose extra values, dates, instances and cached outputs may preserve identity. Transforms can return/mutate application-owned values. Do not rely on parsing to isolate mutable private data.
Bounded execution and issues#
parse, safeParse, their async/parser variants, is, assert and Standard Schema validation use per-call execution state. Internal ~run methods are not a public safety boundary. Limits are positive integers within these hard ceilings:
| Parse option | Default | Maximum | Bound |
|---|---|---|---|
maxDepth | 64 | 128 | Input nesting and nested schema/action dispatch (wrappers also count) |
maxNodes | 10000 | 100000 | Input scan nodes and, separately, schema/action/callback steps |
maxCollectionSize | 1000 | 10000 | Array length, map/set size, own object keys and object schema fields |
maxIssues | 100 | 1000 | Issues generated/reused across actions and attempted union branches |
maxConcurrency | 8 | 32 | Active built-in async refinement/transform callbacks per parse |
The iterative input scan rejects cycles and enumerable accessors without invoking them, including beneath unknown/any. Strings are scalar; use maxLength and HTTP byte caps for their size. Bounds do not sandbox trusted callback code, computed getters in custom schemas, returned functions or schema-conversion tools. Async default/lazy factories must manage their own work; the refinement limiter does not serialize container schemas or application-created inner tasks.
Exhaustion throws ValidationLimitError with a safe code and limit, even from safe-parse APIs; it never returns a successful partial result. maxIssues counts speculative union work, so an expensive union may exhaust it before a later branch could pass. abortEarly:true collects the first encountered failure within containers; abortPipeEarly controls pipelines. Async siblings may already be running when issues are collected; fail-fast output cannot undo started work. Use the execution limits to bound that work.
Built-in issues retain kind, type, path, nested issues, and add the stable code: '<kind>.<type>'. The code is optional on custom issue producers for compatibility. Raw issues still contain rejected values and custom messages: keep them server-side. formatIssues builds a prototype-safe tree; _errors is reserved. HTTP adapters emit generic messages, codes and bounded paths, map budget exhaustion to 413, and propagate operational exceptions/validation cancellation. Body disconnects return 400. JSON request validation observes the Request signal. Validation never grants authorization.
Async validation, hooks and cleanup#
import { string, pipeAsync, checkAsync, safeParseAsync } from '@zeruxjs/validator';
const schema = pipeAsync(string(), checkAsync(async (value, {signal}) => {
signal?.throwIfAborted();
return value.length > 0; // cooperative external validators receive the same signal
}));
const result = await safeParseAsync(schema, 'Ada', {
maxConcurrency: 2,
onError: event => console.log(event.failure, event.issues)
});checkAsync, partialCheckAsync, customAsync and transformAsync receive a second context argument with signal. checkItemsAsync receives it as its fourth argument. Raw async actions receive it through context.config.signal. Async schemas/actions are rejected by sync parsers with AsyncValidationError before invocation, even when nested inside a sync container. Native async functions misused as sync checks/transforms/defaults/raw callbacks also reject before invocation. Thenables returned by an otherwise synchronous callback are rejected and observed to prevent unhandled rejections; code that ran before returning a thenable cannot be undone.
Cancellation rejects with name:'AbortError'; external cancellation reasons remain in cause. On cancellation or operational failure, queued work is prevented from starting and the parse drains already-started callbacks, then removes its root abort listener. Cooperative callbacks must observe their signal and settle; a callback that never settles can delay cleanup indefinitely. No timer/deadline, network retry or background validator starts automatically. Limits/concurrency are per parse, not process-wide or distributed quotas.
onSuccess/onError are synchronous, per-parse notifications. Events contain only success/failure class, issue codes and redacted paths (array indexes remain; field/key segments become <field>). They never include submitted values, custom messages, output, or exception text. Hook exceptions and returned thenables throw ValidationHookError; cleanup has completed before notification. A success hook failure does not invoke the error hook. Do not send raw exception causes to clients. Invalid configuration rejects before execution and is not a hook event.
New execution controls belong on the root parse call. config(schema, ...) continues to configure legacy language/message/abort flags and rejects execution controls. Legacy process-wide message registries remain for compatibility; do not use them for request/tenant-specific handlers. Global config never retains hooks or signals. Explicit signals or limit overrides bypass schema caches so controlled work cannot reuse a computation owned by another parse. Cached issues consume the receiving parse's issue budget. Default cache semantics (including output identity and stale mutable inputs) remain unchanged.
Message registries use exact language keys, including a distinct undefined key; they provide no locale fallback, expiry, cardinality cap or tenant isolation. Configure trusted static messages at bootstrap. Getters return borrowed messages without invoking them; validation may invoke message callbacks with private issue data. Deleting a specific message leaves its constructor bucket allocated.
getDefaults/getFallbacks recursively read declared object entries and tuple positions, not dynamic rest values. They may invoke trusted leaf factories without an input dataset or root parse budget. Async variants start sibling factories concurrently; rejection does not cancel independent factory work. These helpers do not validate replacements or deep-clone their results.
getTitle/getDescription prefer the last matching metadata at the highest pipeline level. getMetadata instead shallowly merges in depth-first source order, with later keys winning. Metadata is host-owned, unvalidated and unescaped; reading record getters may execute host code. Escape text for its eventual output context and keep untrusted records outside this API.
Native grapheme/word actions use the installed runtime’s Intl.Segmenter and ICU data. Word segmenters use a legacy unbounded cache keyed by exact locale value or locale-array identity; keep locale configuration static and bounded. Byte actions allocate a UTF-8 encoding and are separate from streaming HTTP limits.
Map/Set schemas produce fresh native collections while child outputs may retain borrowed identity. Transformed duplicate Map keys use last assignment; Set values collapse under native equality. Async collection schemas start child work before ordered result accumulation, so abortEarly does not prevent every child from starting. Record schemas inspect own enumerable string fields; trusted key transforms must enforce permitted output names because the input-key filter does not revalidate transformed names. Custom predicates assert host-declared types; lazy getters select trusted schemas per parse and are not memoized or sandboxed.
Intersection validation merges compatible branch outputs into the first result. That legacy recursive merge may mutate borrowed objects before a later conflict and has no standalone cycle/depth budget or rollback; trusted transforms must produce compatible finite output graphs. Root input budgets do not make arbitrary host callbacks or their output merge transactional.
Format refinements check retained text patterns/checksums only: URL parsing is not a scheme/egress allowlist, IP checks do not deny private addresses, JWS shape does not verify signatures, and hash checks do not compute digests. ISO patterns are a syntax subset, not complete calendar validation. Native RegExp matching is synchronous; explicit custom patterns and candidate size remain host policy.
Native array callback actions retain native synchronous behavior; their callbacks are not awaited. sortItems mutates its array in place. readonly, brand and flavor change TypeScript inference without freezing, cloning or authorizing runtime values; their declaration-only symbols are not runtime symbol exports. Function argument/result wrappers keep their captured configuration and call the borrowed function without preserving a method receiver. Create wrappers through parseAsync when their child schemas are async. Result validation happens after function side effects and cannot undo them.
The local ajvSafeParse checker does not run functional transforms or the AJV engine. It ignores unsupported keywords, uses legacy inherited-key presence checks and slash-joined paths without full JSON Pointer escaping, and adds no independent scan/cycle/deadline budget. toJsonSchema(schema, {strict:true}) selects the stricter converter policy. Strict mode throws for explicitly unsupported branches but still ignores some refinements and transformations; it does not certify full conversion coverage. Prefer the functional parse APIs when relying on their own-field and root-budget contracts.
Compatibility and verification#
| Surface | Compatibility goal |
|---|---|
| Functional schemas/actions/parsers | Preserve existing named contracts; precise functional inference |
| Constructor action shorthand | Runtime convenience; typed zero-argument shorthand, broader legacy argument-taking forms |
z / chain | Limited convenience API with broad types; not Zod parity |
JSON Schema / ajvSafeParse helpers | Existing limited local conversion/checking subset; not the AJV engine or full JSON Schema |
| Valibot-style names / Standard Schema adapter | Existing named subset; no full Valibot conformance claim |
After changing source, run npm run build, then node --experimental-strip-types --test test/*.test.ts in this package. The foundation suite checks normal/denied inputs, inference against an offline npm-installed archive using the shared TypeScript tooling, unions, default order, limits, async misuse, dependency failures, hook exceptions and cancellation cleanup. It does not exercise a live provider or browser. Installed TypeScript/shared tooling are required; there are no live-service prerequisites.
Browser and HTTP entry points#
| Public import | Contents |
|---|---|
@zeruxjs/validator/schemas | Schemas, actions, parsers and existing utilities; no request-parsing dependency |
@zeruxjs/validator/browser | Same pure API plus explicit DOM feedback; no HTTP reader, Node imports or Node globals |
@zeruxjs/validator/http | Fetch JSON/query/header/form adapters and client-safe error types |
@zeruxjs/validator | Compatibility barrel retaining previous root exports |
The browser entry can be imported before a DOM exists. Browser-only functions access supplied elements only when called. HTTP adapters use Fetch/Web Streams, not Node IncomingMessage; selecting /http does not start a server. Schemas and client types remain validator-owned. Browser declarations require DOM libraries.
import { strictObject, string } from '@zeruxjs/validator/schemas';
import { createHttpInputAdapter } from '@zeruxjs/validator/http';
const schema = strictObject({ name: string() });
const input = createHttpInputAdapter({
maxBytes: 64 * 1024, bodyTimeoutMs: 3000, maxConcurrentReads: 8,
duplicates: 'reject'
});
try {
const value = await input.parseQuery(schema, 'https://example.test/?name=Ada');
console.log(value.name);
} finally { await input.close(); }Create one adapter per application or other deliberate owner to share reader slots. Methods are readJsonBody, parseJsonRequest, parseQuery, parseHeaders, parseFormData and parseFormRequest. Standalone exports create a scope per call; they cannot cap concurrency across separate calls. validatedJsonHandler owns a shared adapter and exposes handler.close() for shutdown. Closing is idempotent, rejects new calls, aborts owned operations and waits for cleanup. It does not publish/register routes or perform authorization/CSRF checks.
HTTP budgets and failures#
| Option | Default | Allowed range |
|---|---|---|
maxBytes | 1 MiB | 1 byte–16 MiB |
bodyTimeoutMs | 3000 ms | 10–60000 ms |
maxConcurrentReads | 32 | 1–1024 |
maxFields | 200 | 1–1000 |
maxQueryFields | maxFields | 1–1000 |
maxQueryLength | 16384 characters | 1–1048576 |
maxFieldBytes | 16384 UTF-8 bytes | 1–1048576 |
Budgets are positive safe integers. Byte limits count actual streamed bytes before JSON/form decoding and compare Content-Length to the final length. Field budgets also bound already-created FormData/header/query input: total name/value UTF-8 bytes plus preserved File sizes must fit maxBytes. JSON key inspection has a 10000 node cap before schema validation. validation supplies the existing schema parse options/limits/hooks, excluding signal. signal on the adapter and Request.signal both cancel that operation; neither overrides the other.
JSON requires application/json or application/*+json. Form requests require application/x-www-form-urlencoded or multipart/form-data with a valid boundary. UTF-8 is required; content encoding other than identity rejects with 415. FormData objects and Headers/pair iterables are already decoded and have no content-type header to validate. Multipart decoding uses the runtime's Fetch parser after the bounded read; field limits are checked after native decoding, not by a streaming multipart parser. File fields default to rejected. {files:'preserve'} permits bounded File objects for schemas to validate; no file bytes or filenames are sent to issue callbacks or automatically written to storage. Upload authorization, media inspection and storage remain application responsibilities.
The body deadline is absolute, includes async decoding, and is not extended by trickled chunks. It covers body consumption, not arbitrary schema refinements or the application handler. Synchronous JSON/native parser work cannot be preempted by a timer; the byte cap bounds its input. Reader-capacity exhaustion returns 429 without consuming the rejected body. Every owned failure path initiates reader cancellation and releases its lock, timer, listener and slot. Cancellation callback rejections remain in the internal cause; an uncooperative cancellation promise cannot keep a reader slot occupied. A reader already locked by someone else is rejected without stealing its lock. Underlying stream producers still must honor the Streams cancellation contract.
Body disconnect/read failures return 400; deadline 408; size/work budgets 413; media type 415; schema issues 422; reader capacity 429. Operational schema/provider exceptions still propagate; validation cancellation remains AbortError. Error responses use fixed generic messages and at most 20 client issues, with no-store headers. HttpValidationError.message and .cause retain internal diagnostics, including raw schema issues; toResponse() never serializes them. Client paths retain only schema-declared fields and numeric array indexes; dynamic record/map keys are redacted. Custom messages and raw passwords/file contents are excluded. Never serialize the Error/cause object yourself.
Field duplicates, absence and coercion#
Query, header and form adapters share the same policies. They collect a null-prototype dictionary and then apply the schema's strip/strict/loose/rest unknown-key policy. Keys are literal, with no dot/bracket expansion. Reserved prototype names, including reserved dot/bracket segments, reject with 400 before schema stripping. Names must be nonempty, at most 256 characters, and contain no controls; headers additionally require token syntax and are lowercased.
| Policy | Behavior |
|---|---|
duplicates:'array' (default) | One occurrence is scalar; repeated occurrences are arrays |
duplicates:'reject' | Any repeated name rejects, including repeats containing empty values |
duplicates:'first' / 'last' | Select the literal first/last occurrence before empty handling |
empty:'preserve' (default) | Empty strings stay empty; absent fields stay absent |
empty:'omit' | Remove selected empty strings; if all disappear, the field is absent and schema defaults may run |
coerce:{field:'number'} | Strict finite JSON-number syntax only; no whitespace, hex, NaN, Infinity or empty conversion |
coerce:{field:'boolean'} | Exact lowercase true/false only |
coerce:{field:'string'} | Preserve strings; do not stringify files |
Only named fields coerce, and repeated selected values coerce individually. No field is created just because it has a coercion rule. Repeated arrays remain arrays after empty filtering; one surviving occurrence is not silently scalarized. There is no schema-driven guesswork or automatic singleton-to-array conversion. JSON retains native JSON types and does not use these string-field coercions. URL percent decoding follows URLSearchParams. Use lower-case coercion names for headers. Native Headers has already merged some wire duplicates into comma-joined strings; adapters neither split commas nor promise recovery of lost wire fields. Use raw header pairs when duplicate detection requires original occurrences.
Form feedback without a frontend framework#
import { createFormFeedback } from '@zeruxjs/validator/browser';
const fields = new Map([
['name', { control: nameInput, description: nameError }]
]); // application-supplied DOM elements; nameError must have a stable id
const feedback = createFormFeedback({
resolveField: path => fields.get(path),
onIssue: ({code}) => console.log(code)
});
feedback.update([{path:'name',code:'schema.string',message:'Invalid input'}]);
// On successful validation: feedback.clear(). On teardown:
feedback.destroy();The host explicitly resolves known paths, renders any summary/root errors and owns submission, focus and announcements. No selectors are interpolated from inputs. Feedback sets aria-invalid, merges a stable description ID into aria-describedby and writes generic text with textContent. It never reads an input value or file. Notifications contain only a sanitized code; custom message text is ignored. Callbacks are synchronous; thrown errors/thenables roll back owned changes and propagate. clear/destroy restore prior ARIA attributes and description text; destroy is idempotent. Do not assign another owner to these same attributes while feedback is active. No framework, DOM listener or background validation starts on import.
The HTTP-input suite additionally checks slow/trickled/disconnected streams, reader overload/cleanup, duplicate/coercion/prototype policies, multipart/file handling, sanitized responses and feedback rollback using a DOM fixture. Its browser bundle check confirms the dependency graph excludes request parsers and checks executable syntax for Node references, including computed/module access. Comments and unrelated string data may name Node APIs without being executed. Installed archive checks cover the new public subpaths. These are Node Fetch/stream and DOM-fixture checks, not live-browser or deployed proxy tests. There are no credentials/live-service prerequisites; a real browser and a host's actual Fetch bridge/proxy are separate integration checks.