@validup/zod
v0.3.3
Published
A validup integration for zod.
Downloads
2,776
Readme
@validup/zod 🛡️
A validup integration for zod — turn any zod schema into a validup Validator.
Wrap any zod schema as a validup Validator, mount it on a Container path, and let validup orchestrate path expansion, group filtering, and error aggregation while zod does the actual schema parsing.
ℹ️ Already on zod 3.24+ and don't need vendor-specific fields?
@validup/standard-schemacovers zod via the Standard Schema protocol and works the same against valibot, arktype, and effect-schema. Pick@validup/zodwhen you specifically needexpected/receivedon issues or the bidirectionalvalidup ↔ zodmapping.
Table of Contents
Installation
npm install @validup/zod validup zod --save| Peer dependency | Supported versions |
|-----------------|---------------------|
| validup | ^1.0.0 |
| zod | ^4.0.0 |
ℹ️ zod 3 support was dropped in
@validup/[email protected]. The adapter'sZodIssuetype alias resolves tozod/v4/core's$ZodRawIssue, which is not available in zod 3. Stay on@validup/zod@<1if you cannot upgrade zod; otherwise upgrade zod to^4.0.0alongside this package.
Quick Start
import { Container, ValidupError } from 'validup';
import { createValidator } from '@validup/zod';
import { z } from 'zod';
const user = new Container<{ email: string; age: number }>();
user.mount('email', createValidator(z.string().email()));
user.mount('age', createValidator(z.number().int().positive()));
try {
const valid = await user.run({
email: '[email protected]',
age: 28,
});
// valid is { email, age }
} catch (error) {
if (error instanceof ValidupError) {
console.log(error.issues);
// [{ type: 'item', path: ['email'], message: 'Invalid email address', ... }]
}
}createValidator returns a validup ValidatorDescriptor — interchangeable with a bare Validator at the mount site. Mount it like any other validator:
container.mount('field', { group: 'create' }, createValidator(z.string()));
container.mount('opt', { optional: true }, createValidator(z.number()));Per-Context Schemas
Pass a function instead of a schema to build the schema lazily from the validator context (e.g. depending on the active group, sibling values, or path):
import { createValidator } from '@validup/zod';
import { z } from 'zod';
const passwordValidator = createValidator((ctx) => {
if (ctx.group === 'create') {
return z.string().min(12); // strict for new users
}
return z.string().min(12).optional(); // lenient on update
});
container.mount('password', { group: 'create' }, passwordValidator);
container.mount('password', { group: 'update' }, passwordValidator);The factory receives the full ValidatorContext:
type ZodCreateFn<C = unknown> = (ctx: ValidatorContext<C>) => ZodType;createValidator<C>(...) is generic over the validup context type, so factories can read typed ctx.context when the parent container declares one (Container<T, C>).
Result Caching
createValidator returns a ValidatorDescriptor (interchangeable with a bare Validator at the mount site). It participates in validup's result cache by default — most zod schemas (z.string().email(), length / regex / enum) are deterministic, so cached (value, context, group) snapshots replay without re-running the schema.
container.mount('email', createValidator(z.string().email())); // cached
container.mount('email', createValidator(asyncZodSchema, { sideEffect: true })); // never cachedPass { sideEffect: true } for schemas with async refines or superRefine calls reading external state — the framework will then re-run them on every invocation, ignoring any cached entry.
Error Mapping
Zod → Validup
When a schema fails to parse, the adapter calls safeParseAsync, then converts each ZodIssue into a validup IssueItem with the original path, message, and (when present) expected / received fields. The adapter then throws a ValidupError containing those issues.
Each zod issue's code is mapped onto validup's IssueCode vocabulary so consumer-side i18n catalogs (e.g. @ilingo/validup) can ship one parameterized message per code instead of falling back to a generic "invalid value" string:
| Zod issue | Validup IssueCode | data |
|--------------------------------------------------------|------------------------------------|---------------------|
| invalid_type (input at path is undefined) | REQUIRED | — |
| invalid_type (wrong type) | VALUE_INVALID | — |
| too_small, origin string / array / set / file| MIN_LENGTH | { min: number } |
| too_big, origin string / array / set / file | MAX_LENGTH | { max: number } |
| too_small, origin number / bigint / date / int | MIN_VALUE | { min: number } |
| too_big, origin number / bigint / date / int | MAX_VALUE | { max: number } |
| invalid_format, format email | EMAIL | — |
| invalid_format, format url | URL | — |
| invalid_format, format uuid / guid | UUID | — |
| invalid_format, format regex | PATTERN | { pattern: string } |
| invalid_format, format date / time / datetime / duration | DATE | — |
| invalid_format, format ipv4 / ipv6 / cidrv4 / cidrv6 | IP_ADDRESS | — |
| invalid_format, format base64 / base64url | BASE64 | — |
| invalid_format, format json_string | JSON | — |
| invalid_value (enum / literal mismatch) | ONE_OF_FAILED | — |
| Everything else (custom, not_multiple_of, unrecognized_keys, invalid_union, …) | VALUE_INVALID | — |
ℹ️ REQUIRED detection requires the input. Zod 4 strips
received/inputfrom the formattedZodError, so the adapter recovers the missing-key signal by looking the issue path up against the original parsed value.createValidatorthreadsctx.valuethrough automatically; if you callbuildIssuesForZodError(error)directly without a second argument, missing keys stay onVALUE_INVALID. Pass the input explicitly (buildIssuesForZodError(error, input)) to opt in.
container.mount('user', createValidator(z.object({
name: z.string(),
age: z.number().min(18),
})));
try {
await container.run({ user: { name: 42, age: 10 } });
} catch (error) {
if (error instanceof ValidupError) {
// error.issues:
// [
// { type: 'group', path: ['user'], issues: [
// { type: 'item', path: ['user', 'name'], message: 'Expected string, received number', ... },
// { type: 'item', path: ['user', 'age'], message: 'Number must be greater than or equal to 18', ... },
// ]}
// ]
}
}Paths from the wrapped zod schema are merged with the parent container's path, so deeply nested validation produces a fully-qualified path array on every issue.
Validup → Zod
Going the other way, you can convert a ValidupError (or a single Issue) into zod's raw issue format — useful when integrating validup output into zod-driven pipelines such as form libraries:
import { buildZodIssuesForError, buildZodIssuesForIssue } from '@validup/zod';
try {
await container.run(input);
} catch (error) {
if (error instanceof ValidupError) {
const zodIssues = buildZodIssuesForError(error);
// [{ code: 'custom', message: '...', path: [...], input: ... }, ...]
}
}⚠️
IssueGroupround-trip is lossy. validup'sIssueis a discriminated union ofIssueItemandIssueGroup. zod has no group equivalent, sobuildZodIssuesForIssuerecursively flattens everyIssueGroupinto its constituentIssueItems when emitting zod issues — group-level metadata (code: 'one_of_failed',data.name, etc.) is dropped. The reverse direction (zod → validup) never produces groups, so azod ← validupround-trip on a flat issue list is preserved; a round-trip that involves grouped errors is not.
API Reference
| Export | Description |
|-------------------------------------|------------------------------------------------------------------------------|
| createValidator(schema, options?) | Wrap a ZodType (or (ctx) => ZodType) as a validup ValidatorDescriptor. options.sideEffect: true bypasses the result cache (use for async refines / superRefine reading external state). |
| buildIssuesForZodError(e, input?) | Convert a ZodError into an array of validup Issues. Pass the parsed input as the second argument to enable invalid_type → REQUIRED promotion for missing keys. |
| buildZodIssuesForError(e) | Convert a ValidupError into an array of zod raw issues. |
| buildZodIssuesForIssue(i) | Convert a single validup Issue into zod raw issues (recurses into groups). |
| ZodIssue | Re-exported alias for $ZodRawIssue from zod/v4/core. |
function createValidator<C = unknown, Z extends ZodType = ZodType>(
input: Z | ((ctx: ValidatorContext<C>) => Z),
options?: { sideEffect?: boolean },
): ValidatorDescriptor<C, ZodOutput<Z>>;Stability
What's covered by semver:
- Public exports —
createValidator,buildIssuesForZodError,buildZodIssuesForError,buildZodIssuesForIssue, and theZodIssuetype alias. - Error-mapping shape —
IssueItem.pathmirrors the failing zod path;IssueItem.codeis mapped onto the validup vocabulary (min_length,max_length,min_value,max_value,email,url,uuid,pattern,date,ip_address,base64,json,required,one_of_failed, …) so consumer-side i18n catalogs can ship one parameterized message per code; unmapped zod codes fall back tovalue_invalid.IssueItem.expected/receivedare passed through when zod exposes them.buildZodIssuesForErrorreconstructs a zod-shaped representation from aValidupError(code: 'custom', the message, the path, and the originalreceivedvalue asinput) — it does not preserve the round-tripped vocabularycode. If you need the original zod codes / vendor fields end-to-end, keep theZodErroravailable alongside theValidupError. - Per-context schema factory —
(ctx: ValidatorContext<C>) => ZodTypeinvocation contract.
Known lossy behavior:
buildZodIssuesForIssueflattensIssueGroups when emitting zod issues (zod has no group concept) — group-levelcodeanddataare dropped.
Peer dependency policy:
validup ^1.0.0,zod ^4.0.0. zod 3.x was dropped in@validup/[email protected]because the adapter'sZodIssuetype alias resolves tozod/v4/core, which is not available in zod 3.
Deprecation policy: matches validup — at least one minor release of @deprecated notice before removal in a major.
License
Made with 💚
Published under Apache 2.0 License.
