Form Validation with Type Inference Made Easy with Zod, the Best Sidekick for TypeScript

Feature image: Form Validation with Type Inference Made Easy with Zod, the Best Sidekick for TypeScript

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!

What is Zod?

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.

How to define Zod schemas

Let's start by installing Zod with our preferred package manager:

npm install zod
# or
yarn add zod
# or
pnpm 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,
  • and 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.

Using schemas to validate data

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.

  • If it passes, the returned object will also contain data with the validated and typed data.
  • If it fails, the returned object will have an 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": []
}
]

How to validate objects, arrays and more

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.

How to customize error messages

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),
})
  • If the user doesn't input an age, the required error message will say Required
  • If, instead of a number, they input the string "twenty-nine", the invalid type error message will be Expected number, received string
  • If they enter the number 29, the validation error message will state Number must be greater than or equal to 35
  • And if they enter 40, it will read Number must be less than or equal to 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.

Custom validation functions

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:

  • A function that processes the value, which should return a truthy value if the validation is successful.
  • An optional object used to customize the error message and path.

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 color
  • isMimeType to check if it is a valid MIME type, like "application/json"
  • isBtcAddress to check if it has the format of a Bitcoin address
  • And many more

Let'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'
})

Type inference for TypeScript

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:

Zod Type Autocomplete

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:

Zod Error Type Autocomplete

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.ts
import { 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.

Final Challenge: a complex checkout form

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:

  • Requires the customer's name (between 4 and 50 characters), email (must be a valid email format), and phone number (exactly 10 digits).

Shipping Address:

  • It consists of the address line 1 (between 5 and 100 characters), address line 2 (optional, up to 100 characters), city (between 2 and 50 characters), state (between 2 and 50 characters), postal code (exactly 5 digits), and country (must be a valid ISO 3166-1 alpha-3 country code).

Items:

  • Represents an array of items being purchased.
  • Each item includes an ISBN (must be a valid ISBN) and quantity (an integer between 1 and 5).

Payment Details:

  • Requires the customer's card number (must be a valid credit card number), expiration date (in the format MM/YY, must not be in the past), and CVV (exactly 3 digits).

Alright! Our checkout.ts module can look like this:

import { z } from 'zod'
 
// Let's import some handy validators
import 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 expired
function 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 schema
const customerInfoSchema = z.object({
name: z.string().min(4).max(50),
email: z.string().email(),
phoneNumber: z.string().regex(/^\d{10}$/)
})
 
// The shipping address schema
const 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 schema
const 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 schema
const 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 it
export const checkoutSchema = z.object({
customerInfo: customerInfoSchema,
shippingAddress: shippingAddressSchema,
paymentDetails: paymentDetailsSchema,
items: itemsSchema
})
 
// Finally, export the type of the object and the type of the errors
export 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:

Zod Validation App Preview

Check out the working example in this Stackblitz or the code on GitHub.

Conclusion

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!

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy