How to use Zod for input validation

by Randy — 10 minutes

On every project I worked on, I had (and still have) critical recurring problems to solve, one of them was form validation. The crucial task of validating data throughout an application's client- and server-side data flow could be difficult. Zod is a TypeScript library for validating and parsing structured data. Using Zod reduces the complexity of your application and by type inferring Zod helps you to add types to your Typescript codebase.

Validation complexity is a common problem, especially in complex forms. For example, in a multistep ordering form in an e-commerce context with contact information, delivery dates, and delivery methods depending on the preferred delivery date. I refer to validation complexity as the validation rules that depend on other inputs and async validation that requires API requests.

What is Zod?

Zod is a TypeScript-first schema validation library with static type inference. Typesafe schemas to parse and validate data like user input and API responses and return validation errors when the data doesn’t match the schema. Typescript’s static type system infers these schemas to a Typescript type to catch potential errors. 

Here’s a simple Zod schema. 

  const LoginSchema = z.object({
    email: z.string().nonEmpty("We need your email"),
    password: z.string().nonEmpty("We need your password"),
  });

With this schema, you can validate an object with fields email and password. 

try {
  const result = LoginSchema.parse({email: "email@email.com", password: "12345678"});
  // result.data is automatically typed by Zod
  // send request with result.data
  // await login(result.data)
} catch(e: ZodError) {
  // Handle error
}

A ZodError including validation errors is thrown when the input object doesn’t validate according to the validation rules of the schema. 

Form validation with a Zod schema

For example, you have a page with an address input section that contains the required fields: street, house number, zip code, and city.

With Zod, you establish a Zod schema AddressSchema that parses the input and returns the potential validation errors in a ZodError or provides parsed data typed by Zod’s type inference feature. See the code block on how to utilize Zod to validate address input.

import { z } from 'zod' // import zod

const AddressSchema = z.object({
  street: z.string().nonEmpty("We need your street"),
  houseNumber: z.string().nonEmpty("We need your house number"),
  zipcode: z.string().nonEmpty("We need your zip code"),
  city: z.string().nonEmpty("We need your city"),
});

// try to parse the input data
const inputData = {}; // Input data from your form
const inputValidationResult = AddressSchema.safeParse(inputData);

// Check if data is parsed successfully
if(result.success) {
  // set state of input fields to valid
  const data: AddressEntity = result.data;

  // create mutation with data as input
} else {
  // Parsing failed return result with validation errors.
  const errors: ZodError = result.errors
  // set state of input fields to invalid with error message
}

First, we import Zod and create an AddessSchema with z.object  and utilize z.string() to add fields;

  • street

  • houseNumber

  • zipcode

  • city

const AddressSchema = z.object({
  street: z.string().nonEmpty("We need your street"),
  houseNumber: z.string().nonEmpty("We need your house number"),
  zipcode: z.string().nonEmpty("We need your zip code"),
  city: z.string().nonEmpty("We need your city"),
});

Zip code validation with regex

We can be more specific using the regex validation with the zip code. For example, in the Netherlands, our postal code system consists of four digits followed by two letters. So we can apply the regex validation rule z.string().regex(/regex/, "validation message") as in the following code example. It's not recommended to use the following regex in production because it's not tested.

zipcode: z.string().nonEmpty("We need your zip code").regex(/^\d{4}\s?[A-Za-z]{2}$/, "your zip code is not correct")

Zod has an extensive list of validation rules you could apply to a string schema.


z.string().email();
z.string().url(); 
z.string().emoji(); 
z.string().uuid();
z.string().regex(regex);
z.string().includes(string);
z.string().endsWith(string);
z.string().datetime(); // defaults to UTC, see below for options
z.string().ip(); // defaults to IPv4 and IPv6

// See [https://zod.dev/?id=strings](https://zod.dev/?id=strings) all the validation functions to apply on a string schema

Secondly, use safeParse to validate the inputData with AddressSchema. 

const inputValidationResult = AddressSchema.safeParse(inputData);

When the data is successfully validated the return value is:

{
    success: true,
    data: {
        street: 'value',
        houseNumber: 'value',
        zipcode: '1111AA',
        city: 'value'
    }
}

It is also typed according to AddressSchema and you can use it to populate an API request like createAddress with an address argument that is typed by z.infer<typeof AddressSchema>


type AddressEntity = {
  street: string;
  houseNumber: string;
  zipcode: string;
  city: string;
}
const inputValidationResult = AddressSchema.safeParse(inputData);
type AddressEntity = z.infer<typeof AddressSchema>

type AddressEntity = {
  street: string;
  houseNumber: string;
  zipcode: string;
  city: string;
}

async function createAddress(address: AddressEntity) {
  // do a POST call
  // return result
}

// Check if data is parsed successfully
if(result.success) {
  
  // set state of input fields to valid
  const {data} = result;
  
  // create POST request with data as input
  const result = await createAddress(data);
}

How to handle Zod errors

AddressSchema.safeParse returns an Error result with a ZodError instance with a list of ZodIssues in case of invalid data

// class ZodError extends Error { issues: ZodIssue[]; }
ZodError: []
    "code": "invalid_type",
    "expected": "invalid_type",
    "received": "invalid_type",
    "path": ["city"],
    "message": "Required"
  }
]

These ZodIssues help you to construct validation messages. For a more comprehensive understanding of ZodIssues, see the documentation https://zod.dev/ERROR_HANDLING?id=zodissue. You can display meaningful error messages corresponding to each input field by leveraging the ‘code’ and 'path' properties. The property code has information about the type of error and the path property represents the field location in the validated object. 

So in the example, the path city is referencing the city property of the input object. So the validation message should be shown close to the input field containing the name attribute with the value city.

Formatting ZodError

The ZodError.format function is beneficial when validating nested objects. The format function transforms the ZodIssues list into an Object. The following code block represents a Zod error including a nested validation issue and the formatted object after format.

// ZodError with a nested validation issue
ZodError: [
  {
    "code": "too_small",
    "expected": "number",
    "received": "number",
    "path": ["person", "age"],
    "message": "You must be 10+"
  }
]

// Formatted ZodError
{
  person: {  
    age: {
      _errors: ["You must be 10+"]
    }
  }
}

A formatted ZodError is a more optimal object to display validation issues close to the respective input field. The code up ahead represents a ZodError containing a nested validation issue. The upcoming code showcases a pattern of using the formatted ZodError in an HTML form.


// data from HTMLFormElement
// const formElement = submitEvent.target;
// const formData = new FormData(formElement);
const data = Object.entries(formData)

// result returned by a ZodSchema.safeParse()
const result = AddressSchema.safeParse(data)

// A formatted ZodError with a nested validation issue
const formattedErrors = result.errors.format();
<form>
  <input type="text" name="person.name" />
  // using formattedErrors to display the validation errors of
  // person.name. _errors is an Array of errors
  <span class="error">{formettedErrors?.person?.name?._errors}</span>
</form>

Type inferences with Zod

Zod has static type inference support to infer a Zod Schema with z.infer. This makes Zod really powerful in Typescript projects. You can even create discriminated unions.

import { z } from 'zod';

const TypeEmail = z.object({
  type: z.literal('email'),
  email: z.string(),
});

type TypeEmail = z.infer<typeof TypeEmail>;

/*
type TypeEmail = {
  type: 'email'
  email: string
}
*/

const TypeTelephone = z.object({
  type: z.literal('telephone'),
  telephone: z.string(),
});

type TypeTelephone = z.infer<typeof TypeTelephone>;

const TypeContact = z.discriminatedUnion("type", [
  TypeEmail,
  TypeTelephone
]);

type TypeContact = z.infer<typeof TypeContact>

/*
type TypeContact = 
  | { type: 'email' email: string }
  | { type: 'telephone'; telephone: string}
*/

The discriminated union type TypeContact could be implemented in a function that executes a request to an API with different endpoints for email and telephone.


function request(input: TypeContact) {
  switch(input.type) {
    case "email":
      // do a request with input.email to api/email
    break;
    case "telephone":
      // do a request with input.telephone to api/telephone
    break;
  }
}

Validating API responses with Zod

You can use Zod to validate API responses by creating a ZodSchema and parse the response. The advantage is that you have runtime validation and the response is fully typed.

import { z } from 'zod'
// Create a schema for what to expect

const APIResponseSchema = z.object({
  email: z.string(),
  name: z.string()
})

// Infer and create the type
type APIResponseType = z.infer<typeof APIResponseSchema>

// Reponse handler with APIResponse matches the success schema
function handleAPIResponse(response: APIResponseType) { 
  // Do something 
}

const response = await fetch('/api').then(response => response.json())
const result = APIResponseSchema.safeParse(response);
// Check if the API response matches the schema
if(result.success) {
      // response matches the schema
     const { data } = result; // data is typed by APIResponseType
    
    // data is typed correctly so it could be an argument for handleAPIResponse
    handleAPIResponse(data);
} else {
    // API Response doesn't match the schema. 
    // Give user feedback
    // Try to match the API response error
}

API responses parsed by Zod are fully typed by the Schema APIResponseSchema. When the return value of APIResponseSchema has the property success the property data is typed and used to call handleApiResponse that has a typed argument response by type APIResponse.

When the server returns a correct success response matching a JSON object with properties: email and name The parsed value is a typed data object. However, if you have to deal with an error response it's possible to create a ZodSchema ensuring the Error is typed correctly.

import { z } from "zod";

// Unauthorized Error schema
const UnauthorizedError = z.object({
  code: z.literal(401),
  message: z.literal("Login Required")
});

// Forbidden Error schema
const ForbiddenError = z.object({
  code: z.literal(403),
  message: z.literal("Forbidden")
});

// Validation error schema
const ValidationError = z.object({
  code: z.literal(400),
  message: z.literal("Validation Error"),
  path: z.string().array(),
  validation: z.enum(["EMPTY", "TOO_SMALL", "TOO_BIG"])
});

// Error schema 
// is a discriminated union of 
// Unauthorized error, Forbidden error, and Validation error
const Error = z.discriminatedUnion("code", [
  ForbiddenError,
  UnauthorizedError,
  ValidationError
]);

This is a representation of three possible error responses utilized by ErrorSchema. This schema ensures that the error response is typed. This reduces the complexity of the error-handling logic.


function handleErrorResponse(errorBody: unknown) {
  
  // use the Error schema for parsing
  const errorResult = Error.safeParse(errorBody)

  // check if object matches the schema
  if(errorResult.success) {

    // handle typed error result
    handleError(errorResult.data)

  } else {

     // Error doesn't match
     //  handle unknown error<i>

</i>  }
}

First, try to parse the error body to ensure a typed Error based on the given error schemas. If the error body matches an Error schema you can handle the error.


function handleError(error: Error) {
  
  switch(error.code) {
      case 400:
          // handle validation error
          // set input errors
      break;
      case 401:
         // handle Login required
      break;
      case 403:
         // handle forbidden error
      break;
   }

}

Zod can do much more to make your codebase reliable

Zod has many features to enhance your codebase significantly. It helps you with runtime validation and typescript compile time. This library has more to offer than just input validation and it has a great ecosystem with many integrations. For example, you can use Zod as a validation library for popular form libraries like sveltekit-superforms, react-hook-form, or @vee-validation/zod. It's even possible to generate Zod schema's from openAPI specs, eliminating the need to write schema's yourself.

meerdivotion

Cases

Blogs

Event