Server-side validation is a must, but client-side validation is also crucial: when a user attempts to submit a form, providing instant feedback and highlighting the fields with invalid data can be an invaluable guide to help them get things done.
There are many ways to handle front-end validation, from adding standard HTML attributes to our inputs (like required
and min
) to writing our own validation functions.
Today, let's explore Zod: a library that can help us validate data not only on the client but also on the server, and even generate TypeScript types to maintain end-to-end type safety and consistency. Let's dive in!
Zod is a TypeScript-first schema declaration and validation library. But what do we mean by "schema"? In this context, a schema is a structure, a set of rules, a data type. With Zod, you can define such a schema, verify if a particular variable adheres to it, and get error messages when it doesn't.
Schemas range from simple strings to complex nested objects and arrays, and are incredibly useful for validating data from any external source: forms, URL query strings, API calls request parameters, and much more.
Zod shines with TypeScript because it lets you infer types from schemas, so you don't have to write the same definition twice. But it can also be used in JavaScript codebases, where you can get runtime type safety even without TypeScript.
Note: We're going to start just by learning how Zod works, but don't worry; later in the post, we'll also cover how to practically integrate it with your existing JavaScript code and frameworks.
Let's start by installing Zod with our preferred package manager:
npm install zod# oryarn add zod# orpnpm install zod
And we'll be ready to use it. For example, let's say we want to ensure a password string is at least eight characters long. Let's define that schema.
import { z } from 'zod' const passwordSchema = z.string().min(8)
We are importing and using z
to create a schema by chaining two methods:
string
to define the type,min
to specify the validation requirement.Zod offers numerous validation utilities for each data type: min
, max
, includes
, url
, and email
are just a few of the validation methods available for the string
type. We'll explore more throughout this article.
Once we have defined our schema, we can check if a given variable matches it using the parse
method:
import { z } from 'zod' const passwordSchema = z.string().min(8) const password = 'abc123' try { passwordSchema.parse(password) console.log('Data is valid!')} catch (error) { console.log(error.issues)}
If the variable does not match the rules defined in the schema, parse
will throw an error. This error object is an instance of ZodError
and contains an issues
array with information about the validation problems. In our example, the array logged to the console will be:
[ { "code": "too_small", "minimum": 8, "type": "string", "inclusive": true, "exact": false, "message": "String must contain at least 8 character(s)", "path": [] }]
If you prefer to receive the parsing result without throwing an error, you can opt to use the safeParse
instead of parse
. This method returns an object with a success
boolean determining whether the validation passed or not.
data
with the validated and typed data.error
key instead, an object of type ZodError
equivalent to what we've seen before.const result = passwordSchema.safeParse(password) if (result.success) { console.log(result.data)} else { console.log(result.error.issues)}
In our "password" example, just one issue was listed in the issues
array. However, multiple validation errors can generate several issues, one for each detected problem. Take, for example, validating a string to ensure it's a valid email address ending with example.com
:
import { z } from 'zod' const emailSchema = z.string().email().endsWith('example.com')
When we evaluate the string potato
against this schema:
const result = emailSchema.safeParse('potato') if (!result.success) console.log(result.error.issues)
... we'll encounter two issues. The first issue will be due to potato
not meeting the criteria of a valid email format, and the second because potato
does not end with example.com
. So the issues
array will be:
[ { "validation": "email", "code": "invalid_string", "message": "Invalid email", "path": [] }, { "code": "invalid_string", "validation": { "endsWith": "example.com" }, "message": "Invalid input: must end with \"example.com\"", "path": [] }]
We can also use Zod to validate complex data structures like objects and arrays. Let's use z.object
to construct a schema to validate data submitted through a "sign up" form.
The data must contain an email and password, as per the rules of our previous examples. Additionally, let's use z.array
to check for an array of at least three hobbies, which should be strings of at least two characters each.
import { z } from 'zod' const signUpSchema = z.object({ email: z.string().email().endsWith('example.com'), password: z.string().min(8), hobbies: z.array(z.string().min(2)).min(3)}) const formData = { email: 'potato', password: '12345678', hobbies: ['writing', 42, 'singing']} const result = signUpSchema.safeParse(formData) if (!result.success) console.log(result.error.issues)
In this example, the data contains a valid password (although not very secure!) but still an invalid email, and one of the hobbies (the integer 42) is invalid. So, the logged array of issues will be:
[ { "validation": "email", "code": "invalid_string", "message": "Invalid email", "path": ["email"] }, { "code": "invalid_string", "validation": { "endsWith": "example.com" }, "message": "Invalid input: must end with \"example.com\"", "path": ["email"] }, { "code": "invalid_type", "expected": "string", "received": "number", "path": ["hobbies", 1], "message": "Expected string, received number" }]
Note that every item in the issues
array includes a path
array, which identifies the key's path that failed validation, such as ["email"]
or ["hobbies", 1]
.
While this is helpful, Zod offers a format
method that returns all issues as a nested object, which is more convenient for displaying validation errors in our user interface. So, if we run:
const errors = result.error.format()
... we'll get:
{ "_errors": [], "email": { "_errors": [ "Invalid email", "Invalid input: must end with \"example.com\"" ] }, "hobbies": { "1": { "_errors": ["Expected string, received number"] }, "_errors": [] }}
... and we'll be able to access the array of _errors
of each field; for example, errors.email?._errors
and errors.hobbies?.[1]?._errors
.
Then, we can display each array of messages in our template beneath the respective input field, either by iterating over it or merging it into a single string.
Zod's default error messages are helpful, but we might need to make some that are a bit more user-friendly. Let's say we have an application form for a CrossFit competition for ages 35 to 39.
import { z } from 'zod' const participantSchema = z.object({ name: z.string().min(2).max(20), age: z.number().min(35).max(39),})
It's a start, but we can enhance the clarity of those messages. We can customize them like this:
import { z } from 'zod' const participantSchema = z.object({ name: z.string().min(2).max(20), age: z.number({ required_error: 'Please enter your age', invalid_type_error: 'Please enter a valid number' }).min(35, { message: 'Sorry! You need to be at least 35 years old to participate.' }).max(39, { message: 'Sorry! The age limit is 39. More competitions coming soon.' })})
Check out the ZodErrorMap documentation to learn more about customizing errors.
Zod's validation methods are fantastic, but sometimes, you might need something explicitly tailored to your requirements. For such cases, you can implement custom validation logic using the refine
method. This function takes two parameters:
Suppose we need to verify if a given string is a palindrome. We can create an isPalindrome
function and utilize it in a schema like this:
import { z } from 'zod' function isPalindrome(word: string) { const s = word.toLowerCase() return s === s.split('').reverse().join('')} const palindromeSchema = z.string().refine( val => isPalindrome(val), { message: 'The word must be a palindrome' }) const result = palindromeSchema.safeParse('Racecar')
Furthermore, it's possible to import validation functions from other libraries. In its documentation, Zod links to validator.js, a library that offers a wide range of useful string validators, such as:
isHexColor
to check if the string is a valid hexadecimal colorisMimeType
to check if it is a valid MIME type, like "application/json"isBtcAddress
to check if it has the format of a Bitcoin addressLet's see it in action:
import { z } from 'zod'import isMACAddress from 'validator/lib/isMACAddress' const ipMacSchema = z.object({ ip: z.string().ip(), macAddress: z.string().refine( val => isMACAddress(val), { message: 'Please enter a valid MAC address' } )}) const result = ipMacSchema.safeParse({ ip: '245.108.222.0', macAddress: '00-B0-D0-63-C2-26'})
If you're working with TypeScript, defining both a type and a Zod schema might be redundant. Let's say we have a task object:
const taskSchema = z.object({ title: z.string(), points: z.number(), completed: z.boolean(), comment: z.string().optional()}) type Task = { title: string; points: number; completed: boolean; comment?: string | undefined;}
Instead of defining the type separately, we can infer it directly from the Zod schema using z.infer
:
const taskSchema = z.object({ title: z.string(), points: z.number(), completed: z.boolean(), comment: z.string().optional()}) -type Task = {- title: string;- points: number;- completed: boolean;- comment?: string | undefined;-} +type Task = z.infer<typeof taskSchema>
The outcome will be exactly the same:
And when it comes to errors, having them typed is also beneficial. To type the return of format
, we can use z.inferFormattedError
like this:
type FormattedErrors = z.inferFormattedError<typeof taskSchema> if (!result.success) { const errors: FormattedErrors = result.error.format()}
This enables us to leverage our IDE's IntelliSense for autocomplete and type checking:
Moreover, we can export the schema, the type, and the error type from an ESM module and import them whenever needed. For example, you can create this task.ts
file:
import { z } from 'zod' export const taskSchema = z.object({ title: z.string(), points: z.number(), completed: z.boolean(), comment: z.string().optional()}) export type Task = z.infer<typeof taskSchema>export type TaskErrors = z.inferFormattedError<typeof taskSchema>
And import them elsewhere in your project. For example, in a Vue component:
<script setup lang="ts">import { ref } from 'vue'import { taskSchema, type Task, type TaskErrors } from '@/task' const form = ref<Task>({ title: '', points: 0, completed: false })const errors = ref<TaskErrors>() function submit() { const result = taskSchema.safeParse(form.value) if (!result.success) { errors.value = result.error.format() return } // The task data is validated and typed. We can process // it as needed, such as submitting it to an API. console.log(result.data)}</script> <template> <!-- Your template here --></template>
And if you are using TypeScript in a Node backend, you get double the benefit because you can use the module in both your front-end and back-end code. Yes, you can use Zod in Node and other JavaScript runtime environments like Deno, Bun, or Cloudflare Workers.
For example, take a look at this Nuxt example. We import the taskSchema
from the same task.ts
file and use it to validate the body of an API request in a Nuxt server endpoint.
// server/api/tasks.posts.tsimport { taskSchema } from '@/task' export default defineEventHandler(async (event) => { // Parse and validate the request body using the task schema const result = await readValidatedBody( event, body => taskSchema.safeParse(body) ) // Return an error if validation fails if (!result.success) { throw createError({ status: 422, data: result.error.format() }) } // The task data is validated and typed. We can process // it as needed, such as saving it to a database. console.log(result.data) // And return a response return result.data})
Pretty cool, right? We're capitalizing on schema validation on both the frontend and backend, all from a single source of truth. End to end type safety, powered by Zod.
To conclude this article, let's showcase an example of form validation using Zod and Vue. Imagine we manage a bookstore and need to validate a checkout form where people provide their personal and payment details, along with the ISBN codes of the books they want to purchase.
Of course, in a real-world scenario, this form might be broken down into various components, and the selection of books could be more user-friendly than typing an alphanumeric code. However, this complex example is perfect for this tutorial, so we can apply everything we have learned so far.
Let's specify our bookstore checkout form schema:
Customer Information:
Shipping Address:
Items:
Payment Details:
Alright! Our checkout.ts
module can look like this:
import { z } from 'zod' // Let's import some handy validatorsimport isCreditCard from 'validator/lib/isCreditCard'import isISBN from 'validator/lib/isISBN'import isISO31661Alpha3 from 'validator/lib/isISO31661Alpha3'import isPostalCode from 'validator/lib/isPostalCode' // And define a custom one to check that the credit card has not expiredfunction isExpirationDateValid(expirationDate: string): boolean { const [currentMonth, currentYear] = new Date() .toLocaleDateString('en', { month: '2-digit', year: '2-digit' }) .split('/') .map(i => parseInt(i)) const [month, year] = expirationDate.split('/').map(i => parseInt(i)) if (month < 1 || month > 12) return false return year > currentYear || (year === currentYear && month >= currentMonth)} // Now, create the customer info schemaconst customerInfoSchema = z.object({ name: z.string().min(4).max(50), email: z.string().email(), phoneNumber: z.string().regex(/^\d{10}$/)}) // The shipping address schemaconst shippingAddressSchema = z.object({ addressLine1: z.string().min(5).max(100), addressLine2: z.string().max(100), city: z.string().min(2).max(50), state: z.string().min(2).max(50), postalCode: z.string().refine(val => isPostalCode(val, 'US'), { message: 'Invalid postal code' }), country: z.string().refine(val => isISO31661Alpha3(val), { message: 'Invalid country code' })}) // And the payment details schemaconst paymentDetailsSchema = z.object({ cardNumber: z.string().refine(val => isCreditCard(val), { message: 'Invalid credit card number' }), expirationDate: z .string() .regex(/^\d{2}\/\d{2}$/) .refine(val => isExpirationDateValid(val), { message: 'Invalid or past expiration date' }), cvv: z.string().regex(/^\d{3}$/)}) // Finally, an items schemaconst itemsSchema = z .array( z.object({ isbn: z.string().refine(val => isISBN(val), { message: 'Invalid ISBN' }), quantity: z.number().int().min(1).max(5) }) ) .min(1, { message: 'Select at least one book' }) // And bring it all together and export itexport const checkoutSchema = z.object({ customerInfo: customerInfoSchema, shippingAddress: shippingAddressSchema, paymentDetails: paymentDetailsSchema, items: itemsSchema}) // Finally, export the type of the object and the type of the errorsexport type Checkout = z.infer<typeof checkoutSchema>export type CheckoutErrors = z.inferFormattedError<typeof checkoutSchema>
Now, let's move to the Vue part. The CheckoutForm.vue
component code could look something like this:
<script setup lang="ts">import { ref } from 'vue'import { checkoutSchema, type Checkout, type CheckoutErrors } from '@/checkout' const errors = ref<CheckoutErrors>() const form = ref<Checkout>({ customerInfo: { name: '', email: '', phoneNumber: '' }, shippingAddress: { addressLine1: '', addressLine2: '', city: '', state: '', postalCode: '', country: '' }, items: [], paymentDetails: { cardNumber: '', expirationDate: '', cvv: '' }}) function submit() { const result = checkoutSchema.safeParse(form.value) if (!result.success) { errors.value = result.error.format() return } console.log(result.data)}</script> <template> <form @submit.prevent="submit"> <label> <span>Name:</span> <input v-model="form.customerInfo.name" type="text" required /> <FormError :errors="errors?.customerInfo?.name?._errors" /> </label> <!-- Here the rest of the `customerInfo` fields --> <!-- Here all the `shippingAddress` fields --> <!-- And here the `paymentDetails` fields --> <div v-for="(item, index) in form.items" :key="index"> <label> <span>ISBN:</span> <input v-model="item.isbn" type="text" required /> <FormError :errors="errors?.items?.[index]?.isbn?._errors" /> </label> <label> <span>Quantity:</span> <input v-model="item.quantity" type="number" required /> <FormError :errors="errors?.items?.[index]?.quantity?._errors" /> </label> <button @click="form.items.splice(index, 1)"> Remove Book </button> </div> <button type="button" @click="form.items.push({ isbn: '', quantity: 0 })"> Add Book </button> <FormError :errors="errors?.items?._errors" /> <button type="submit">Checkout</button> </form></template>
To display the errors, let's use a super simple FormError.vue
component:
<script setup lang="ts">defineProps<{ errors?: string[]}>()</script> <template> <span v-if="errors?.length"> {{ errors.join(', ') }} </span></template>
And done! With a little bit of styling, our checkout form could look like this:
Check out the working example in this Stackblitz or the code on GitHub.
Now, if your boss calls you at 3 AM and says, "The site went viral and has no front-end validation! We are getting millions of invalid form submissions; our servers are crashing!" you can adjust your dark glasses and say, "I've got this, boss. I know how to Zod."
Well, yeah, that's highly unlikely. Not only because your boss is cool and will never call at 3 AM, but also because a server can easily handle invalid form requests. We don't implement front-end validation for the sake of the server (because it can always be bypassed), but for the sake of the user. If we can point out the errors before the data goes out through the wire, feedback is faster, and faster is better.
So, adjust your dark glasses anyway, and get ready to Zod. Until next time!
We appreciate your interest.
We will get right back to you.