Zod + Vue

Type-Safe Schemas, Both Runtime & TS

Zod + Vue

Zod schemas double as runtime validators and TypeScript types. Combine them with VeeValidate or a small custom hook for fully typed forms.

4 min read Level 3/5 #vue#forms#zod
What you'll learn
  • Define a Zod schema
  • Derive the TS type with z.infer
  • Wire it into vee-validate with the zod adapter

Zod is a TypeScript-first schema library. Define your form shape once and you get runtime validation, parsing, and a static type — all derived from the same source.

Define a Schema

import { z } from 'zod'

export const SignupSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'Min 8 characters'),
  age: z.coerce.number().int().min(18, 'Must be 18+'),
  newsletter: z.boolean().default(false),
})

export type SignupForm = z.infer<typeof SignupSchema>

z.infer extracts the shape so SignupForm is { email: string; password: string; age: number; newsletter: boolean } — no duplication.

Validating Manually

Pass user input through .safeParse and inspect the result:

const result = SignupSchema.safeParse(formValues)
if (!result.success) {
  // result.error.flatten().fieldErrors → { email: ['...'], ... }
} else {
  // result.data is fully typed and parsed
  await api.signup(result.data)
}

With VeeValidate

Install the Zod adapter to feed schemas into useForm:

npm install @vee-validate/zod
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { SignupSchema } from './schema'

const { defineField, handleSubmit, errors } = useForm({
  validationSchema: toTypedSchema(SignupSchema),
})

const [email] = defineField('email')
const [password] = defineField('password')

const onSubmit = handleSubmit(values => {
  // values is typed as SignupForm
  console.log(values)
})
</script>

Refinements and Cross-Field Rules

Zod’s .refine adds custom rules — handy for “passwords match” checks:

const Schema = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).refine(d => d.password === d.confirm, {
  message: 'Passwords do not match',
  path: ['confirm'],
})
File Uploads →