Skip to content

feat(zod-package): support for Zod v4 #5032

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added packages/zod-next/CHANGELOG.md
Empty file.
117 changes: 117 additions & 0 deletions packages/zod-next/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# @vee-validate/zod

<p align="center">
<a href="https://vee-validate.logaretm.com/v4/integrations/zod-schema-validation/" target="_blank">
<img width="150" src="https://github.com/logaretm/vee-validate/raw/main/logo.png">
</a>

<a href="https://github.com/colinhacks/zod/" target="_blank">
<img width="150" src="https://github.com/colinhacks/zod/raw/master/logo.svg">
</a>
</p>

> Official vee-validate integration with Zod schema validation

<p align="center">
<a href="https://github.com/sponsors/logaretm">
<img src='https://sponsors.logaretm.com/sponsors.svg'>
</a>
</p>

## Guide

[Zod](https://github.com/colinhacks/zod/) is an excellent library for value validation which mirrors static typing APIs.

In their own words it is a:

> TypeScript-first schema validation with static type inference

You can use zod as a typed schema with the `@vee-validate/zod` package:

```sh
# npm
npm install @vee-validate/zod
# yarn
yarn add @vee-validate/zod
# pnpm
pnpm add @vee-validate/zod
```

The `@vee-valdiate/zod` package exposes a `toTypedSchema` function that accepts any zod schema. Which then you can pass along to `validationSchema` option on `useForm`.

This makes the form values and submitted values typed automatically and caters for both input and output types of that schema.

```ts
import { useForm } from 'vee-validate';
import { object, string } from 'zod';
import { toTypedSchema } from '@vee-validate/zod';

const { values, handleSubmit } = useForm({
validationSchema: toTypedSchema(
object({
email: string().min(1, 'required'),
password: string().min(1, 'required'),
name: string().optional(),
})
),
});

// ❌ Type error, which means `values` is type-safe
values.email.endsWith('@gmail.com');

handleSubmit(submitted => {
// No errors, because email is required!
submitted.email.endsWith('@gmail.com');

// ❌ Type error, because `name` is not required so it could be undefined
// Means that your fields are now type safe!
submitted.name.length;
});
```

### Zod default values

You can also define default values on your zod schema directly and it will be picked up by the form:

```ts
import { useForm } from 'vee-validate';
import { object, string } from 'zod';
import { toTypedSchema } from '@vee-validate/zod';

const { values, handleSubmit } = useForm({
validationSchema: toTypedSchema(
object({
email: string().default('[email protected]'),
password: string().default(''),
})
),
});
```

Your initial values will be using the schema defaults, and also the defaults will be used if the values submitted is missing these fields.

### Zod preprocess

You can also define preprocessors to cast your fields before submission:

```ts
import { useForm } from 'vee-validate';
import { object, number, preprocess } from 'zod';
import { toTypedSchema } from '@vee-validate/zod';

const { values, handleSubmit } = useForm({
validationSchema: toTypedSchema(
object({
age: preprocess(val => Number(val), number()),
})
),
});

// typed as `unknown` since the source value can be anything
values.age;

handleSubmit(submitted => {
// will be typed as number because zod made sure it is!
values.age;
});
```
49 changes: 49 additions & 0 deletions packages/zod-next/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@vee-validate/zod-next",
"version": "4.15.0",
"description": "vee-validate integration with zod schema validation",
"author": "Abdelrahman Awad <[email protected]>",
"license": "MIT",
"module": "dist/vee-validate-zod.mjs",
"unpkg": "dist/vee-validate-zod.iife.js",
"main": "dist/vee-validate-zod.mjs",
"types": "dist/vee-validate-zod.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/vee-validate-zod.d.ts",
"import": "./dist/vee-validate-zod.mjs",
"require": "./dist/vee-validate-zod.cjs"
},
"./dist/*": "./dist/*"
},
"homepage": "https://vee-validate.logaretm.com/v4/integrations/zod-schema-validation/",
"repository": {
"url": "https://github.com/logaretm/vee-validate.git",
"type": "git",
"directory": "packages/zod-next"
},
"sideEffects": false,
"keywords": [
"VueJS",
"Vue",
"validation",
"validator",
"inputs",
"form"
],
"files": [
"dist/*.js",
"dist/*.d.ts",
"dist/*.cjs",
"dist/*.mjs"
],
"dependencies": {
"type-fest": "^4.8.3",
"vee-validate": "workspace:*"
},
"peerDependencies": {
"zod": "^4.0.0-beta.20250424T163858",
"@zod/core": "^0.9.0"
}
}
180 changes: 180 additions & 0 deletions packages/zod-next/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { input, output, $ZodIssue, ParseContext } from '@zod/core';
import { ZodType, ZodArray, ZodObject, ZodDefault } from 'zod';
import { PartialDeep } from 'type-fest';
import { isNotNestedPath, type TypedSchema, type TypedSchemaError, cleanupNonNestedPath } from 'vee-validate';
import { isIndex, isObject, merge, normalizeFormPath } from '../../shared';

/**
* Transforms a Zod object schema to Yup's schema
*/
export function toTypedSchema<TSchema extends ZodType, TOutput = output<TSchema>, TInput = PartialDeep<input<TSchema>>>(
zodSchema: TSchema,
opts?: Partial<ParseContext<$ZodIssue>>,
): TypedSchema<TInput, TOutput> {
const schema: TypedSchema = {
__type: 'VVTypedSchema',
async parse(value) {
const result = await zodSchema.safeParseAsync(value, opts ?? {});
if (result.success) {
return {
value: result.data,
errors: [],
};
}

const errors: Record<string, TypedSchemaError> = {};
processIssues(result.error.issues, errors);

return {
errors: Object.values(errors),
};
},
cast(values) {
try {
return zodSchema.parse(values);
} catch {
// Zod does not support "casting" or not validating a value, so next best thing is getting the defaults and merging them with the provided values.
const defaults = getDefaults(zodSchema);
if (isObject(defaults) && isObject(values)) {
return merge(defaults, values);
}

return values;
}
},
describe(path) {
try {
if (!path) {
return {
required: !zodSchema.isOptional(),
exists: true,
};
}

const description = getSchemaForPath(path, zodSchema);
if (!description) {
return {
required: false,
exists: false,
};
}

return {
required: !description.isOptional(),
exists: true,
};
} catch {
if (__DEV__) {
// eslint-disable-next-line no-console
console.warn(`Failed to describe path ${path} on the schema, returning a default description.`);
}

return {
required: false,
exists: false,
};
}

Check warning on line 76 in packages/zod-next/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/zod-next/src/index.ts#L67-L76

Added lines #L67 - L76 were not covered by tests
},
};

return schema;
}

function processIssues(issues: $ZodIssue[], errors: Record<string, TypedSchemaError>): void {
issues.forEach(issue => {
const path = normalizeFormPath(issue.path.join('.'));
if (issue.code === 'invalid_union') {
processIssues(
issue.errors.flatMap(ue => ue),
errors,
);

if (!path) {
return;
}
}

if (!errors[path]) {
errors[path] = { errors: [], path };
}

errors[path].errors.push(issue.message);
});
}

// Zod does not support extracting default values so the next best thing is manually extracting them.
// https://github.com/colinhacks/zod/issues/1944#issuecomment-1406566175
function getDefaults<Schema extends ZodType>(schema: Schema): unknown {
if (!(schema instanceof ZodObject)) {
return undefined;
}

return Object.fromEntries(
Object.entries(schema.shape).map(([key, value]) => {
if (value instanceof ZodDefault) {
return [key, value._def.defaultValue()];
}

if (value instanceof ZodObject) {
return [key, getDefaults(value)];
}

return [key, undefined];
}),
);
}

/**
* @deprecated use toTypedSchema instead.
*/
const toFieldValidator = toTypedSchema;

/**
* @deprecated use toTypedSchema instead.
*/
const toFormValidator = toTypedSchema;

export { toFieldValidator, toFormValidator };

function getSchemaForPath(path: string, schema: ZodType): ZodType | null {
if (!isObjectSchema(schema)) {
return null;
}

Check warning on line 142 in packages/zod-next/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/zod-next/src/index.ts#L141-L142

Added lines #L141 - L142 were not covered by tests

if (isNotNestedPath(path)) {
return schema.shape[cleanupNonNestedPath(path)];
}

const paths = (path || '').split(/\.|\[(\d+)\]/).filter(Boolean);

let currentSchema: ZodType = schema;
for (let i = 0; i <= paths.length; i++) {
const p = paths[i];
if (!p || !currentSchema) {
return currentSchema;
}

if (isObjectSchema(currentSchema)) {
currentSchema = currentSchema.shape[p] || null;
continue;
}

if (isIndex(p) && isArraySchema(currentSchema)) {
currentSchema = currentSchema._zod.def.element;
}
}

return null;
}

Check warning on line 168 in packages/zod-next/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/zod-next/src/index.ts#L166-L168

Added lines #L166 - L168 were not covered by tests

function getDefType(schema: ZodType) {
return schema._zod.def.type;
}

function isArraySchema(schema: ZodType): schema is ZodArray<any> {
return getDefType(schema) === 'array';
}

function isObjectSchema(schema: ZodType): schema is ZodObject<any> {
return getDefType(schema) === 'object';
}
Loading