Skip to main content
Form Schema Validation with Zod and React Hook Form
23 min read

Form Schema Validation with Zod and React Hook Form

Introduction

Form field validation with schema is an important aspect of implementing advanced forms in a frontend application. A form schema helps define concrete shape of the data handled by a form.

In a TypeScript based React form, schema validation involves proper type declaration and annotation of the database entity handled. As data entities or resources in an application grows, type overlap, mapping, interconversion, derivation and other manipulation becomes inevitable. This makes static typing of form schema from scratch cumbersome in a growing codebase.

Zod is a TypeScript-first library that addresses these issues. It provides a comprehensive list of battle tested APIs for declaring and applying well typed form schema validations in a React based form. With its validator methods for declaring primitives, objects, and schema derivation APIs that mirror those in TypeScript, Zod makes static typing of form schema extremely versatile. Zod comes with handful of extra features for implementing highly specific validation rules with its validator precision, refinement and transformation methods. It is a dependency-free library that complements well with leading React based form solutions such as Formik and React Hook Form.

In this post, we demonstrate how to use some of the major Zod APIs for implementing properly typed form schema validations with Zod and React Hook Form, in a TypeScript based React application. This post is about how to implement Zod schema validation with React Hook Form. We cover the basics with an example that adopts Zod on top of plain React Hook Form, as well as some important Zod APIs that allow more refined schema validations.

Overview

We first make sense of what a form schema is, its importance in large code bases and why we need proper static typing of form schema validations in a TypeScript based React application. We relate how Zod helps implement well typed form schema in a React application and discuss the role of the Zod resolver for React Hook Form in integrating Zod schemas into React Hook Form validations.

We cover Zod basics while migrating a form in an existing plain React Hook Form based app to Zod. We elaborate, with examples from a Create Post form, what a zod schema and validators are, and how to use Zod primitives (such as string) to declare validators. We learn how they help compose complex data structures with the object() method, and demonstrate examples of Zod's validator precision APIs such as min(), max(), email(), etc. We explore how the parse() method plays a central role in deciding the runtime schema used for validations. We also examine how Zod types are generated from a schema with the infer() API.

In the later half of the post, we consider an <EditProfile /> form component to implement Zod default values with the default() method. We cover how Zod allows intuitive derivation of schemas via TypeScript-like utilities such as partial(), pick() and omit(). We use them to help easily implement complex form field requirements that are normally time consuming otherwise.

Towards the end, we discuss Zod refinements and transformations. We examine examples of composing custom accurate rules with the refine() method and transforming field values with transform().

Prerequisites

In order to properly follow this post and test out the examples, we expect that you come familiar with Formik or React Hook Form based React applications. If you have not worked with any of these yet, it would be useful to code along with the React Hook Form application in this Refinedev Blog post on Essentials of Managing Form State with React Hook Form.

It is also expected that you are familiar with basic type declaration, annotation in TypeScript as well as some type transformation utilities here.

For this post, we start off with the code in this repository. We make necessary changes during the adoption of Zod, and present relevant snippets while explaining underlying concepts.

What is Zod ?

Zod is a Typescript-first form schema declaration and validation library. Zod can be used in any React application. It's API is dedicated to declaring and deriving type safe schemas for database entities that are commonly handled in typical resource based form applications.

Why We Should Use Form Schemas

Form schemas in general are beneficial as they are focused on a data entity handled in a form. Schemas make form field declaration and validations easy to implement. Schemas are also useful for writing DRY (Don't Repeat Yourself). They contribute to code stability, maintainability and scalability by keeping declarations consistent as application entities increase.

Why Zod is Special ?

Zod, as a well tested schema validation library, is useful in implementing feature rich form experiences with versatile solutions like Formik and React Hook Form.

Other schema validation libraries like Yup and Joi exist. However, what sets Zod apart is its extensive static typing API surface that mirrors TypeScript's APIs for type declaration, derivation, inference and other sorts of manipulation that works in combination with the data itself. This makes Zod extremely friendly in growing TypeScript code-planets, where type mapping, derivation and data manipulation become unavoidable as application entities increase.

How Zod Works

Here's how Zod works:

  • Zod gives a Zod instance with the zod object (or any other identifier) which exposes APIs for declaring validators.
  • Zod validators are individual validation declarations. The simplest of validators would be a primitive type and have relevant error messages. A validator in Zod typically represents a form field or property of a database entity. Zod's validators can add necessary precision rules, rule refinements and / or transformations.
  • Zod primitives are validator declaration methods that represent typical JS / TS primitives such as strings, numbers, booleans, etc. A string validator is declared with the string() method, a number with the number() method on the Zod instance, and so on.
  • Zod object schemas represent a database entity with all its individual attributes. Zod object schemas are initialized with the object() method and composed from primitives.
  • Data precision in Zod can be implemented with precision validator APIs. For example, we can implement a string's precision with min(), max(), email() and others.
  • Zod schema derivation / manipulation can be done according to need with full TypeScript support. For example, derivation with partial(), pick() and omit() are common.
  • Validator refinements with custom requirements can be implemented to run nuanced form validations. Zod offers refinements with refine() and superRefine().
  • Form field data can be transformed with Zod transformations. The transform() method is used for this.
  • Type generation from a Zod schema are done with the infer() method on the schema.
  • Zod validation runs are performed with the parse() method. Running the validations checks for accuracy of the form field data according to declared validators. Any error is returned to Zod schema / form instance.

Zod Resolver for React Hook Form

When using Zod with React Hook Form, we need to use the Zod resolver for React Hook Form. React Hook Form supports Zod via its Zod Resolver package. The job of the Zod resolver is to trigger validations in accordance with events taking place in a React Hook Form, translate executed validations and return the result (success or failures) to React Hook Form's instance.

Zod Schema Validation with TypeScript: How to Migrate from Plain React Hook Form

In this section, we adopt Zod schema validations in an existing plain React Hook Form based form. The code for the application is available in this repository. We suggest you make a local clone and then code along from there.

Starter Files

The form we are going to apply Zod on is inside the App.tsx file. It contains a fully functioning RHF based Create Post form. We expect you are already familiar with the code here.

If not, please follow this Refinedev blog post here.

The App.tsx Component

The code for the App.tsx file is given below:

Show App.tsx code
./src/App.tsx
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import "./App.css";

function App() {
const formInstance = useForm({
mode: "onChange",
defaultValues: {
title: "",
subtitle: "",
content: "",
},
criteriaMode: "all",
shouldFocusError: true,
});

useEffect(() => {
formInstance?.reset();
}, []);

return (
<div className="min-h-screen flex items-center justify-center w-full dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg px-8 py-6 max-w-md">
<h1 className="text-2xl font-bold text-center mb-4 dark:text-gray-200">
Create Post
</h1>
<form
onSubmit={formInstance?.handleSubmit((data) => {
setTimeout(() => {
console.log("data", data);
formInstance?.setError("subtitle", {
message: new Error("Server Error: Subtitle field is protected")
.message,
});
}, 2000);
})}
>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title
</label>
<input
{...formInstance?.register("title", {
required: "Post title cannot be empty",
})}
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add post title"
/>
{formInstance?.formState.errors?.title && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.title?.message}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Subtitle
</label>
<input
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add a subtitle"
{...formInstance?.register("subtitle", {
maxLength: {
value: 65,
message: "Keep subtitle shorter",
},
})}
/>
{formInstance?.formState.errors?.subtitle && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.subtitle?.message}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content
</label>
<textarea
cols={40}
rows={5}
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add content here"
{...formInstance?.register("content", {
required: "Content cannot be empty",
minLength: {
value: 20,
message: "Content should have enough information",
},
maxLength: {
value: 1000,
message:
"Content has reached maximum limit of 1000 characters",
},
})}
></textarea>
{formInstance?.formState.errors?.content && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.content?.message}
</span>
)}
</div>
<div className="flex justify-between">
<button
disabled={!formInstance?.formState?.isValid}
type="submit"
className="w-40 disabled:bg-gray-300 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create Post
</button>
</div>
</form>
</div>
</div>
);
}

export default App;

This form uses a React Hook Form instance with the following configurations:

const formInstance = useForm({
mode: "onChange",
defaultValues: {
title: "",
subtitle: "",
content: "",
},
criteriaMode: "all",
shouldFocusError: true,
});

Notice, the form is in onChange mode, which runs validations on each value change. More importantly, we have defaultValues set, which lets TypeScript infer the shape of the entire form's data -- in effect, the schema.

The form fields use React Hook Form's native validation rules set on each form field with the register() API.

For example:

<input
{...formInstance?.register("title", {
required: "Post title cannot be empty",
})}
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add post title"
/>

Notice also, that the React Hook Form formInstance handleSubmit() handler on the onSubmit event in <form> element:

<form
onSubmit={formInstance?.handleSubmit((data) => {
setTimeout(() => {
console.log("data", data);
formInstance?.setError("subtitle", {
message: new Error("Server Error: Subtitle field is protected").message,
});
}, 2000);
})}
></form>

It would typically be some data fetching function that uses fetch() API, React Query mutation, or Axios that performs a POST or PUT/PATCH method. For the purpose of this demonstration, we emulate a dummy server side error integration on subtitle field.

Please feel free to play around with the features and test out the validations. We'll compare them while replacing the rules with Zod schema validators:

typescript zod

In the following sections, we migrate the above React Hook Form based form to Zod. Before we make the necessary changes, we need to install the packages.

Zod with React Hook Form: Installing Packages

We need to install the npm package for Zod and its dependencies. Run the following command:

npm install zod @hookform/resolvers

This should place both packages inside package.json. Take special note of @hookform-resolvers package. This is an integration package for using schema validation libraries with React Hook Form. We need to use the Zod resolver from this package. Otherwise, Zod alone won't work with React Hook Form.

How to Implement Zod Schema Validation with React Hook Form

Now, let's make changes to the existing form. In the below adoption, we instantiate a Zod object schema with individual validators declared for the form fields:

Show updated `App.tsx` with Zod schema
./src/App.tsx
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import * as zod from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import "./App.css";

function App() {
const subtitle = zod.string().max(65, { message: "Keep subtitle shorter" });
const content = zod
.string()
.min(20, { message: "Content should have enough information" })
.max(1000, {
message: "Content has reached maximum limit of 1000 characters",
});

const PostSchema = zod.object({
title: zod.string().min(1, { message: "Title cannot be empty" }),
subtitle,
content,
});

type TPost = zod.infer<typeof PostSchema>;

const formInstance = useForm({
resolver: zodResolver(PostSchema),
mode: "onChange",
defaultValues: {
title: "",
subtitle: "",
content: "",
},
criteriaMode: "all",
shouldFocusError: true,
});

useEffect(() => {
formInstance?.reset();
}, []);

return (
<div className="min-h-screen flex items-center justify-center w-full dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg px-8 py-6 max-w-md">
<h1 className="text-2xl font-bold text-center mb-4 dark:text-gray-200">
Create Post
</h1>
<form
onSubmit={formInstance?.handleSubmit((data) => {
setTimeout(() => {
console.log("data", data);
formInstance?.setError("subtitle", {
message: new Error("Server Error: Subtitle field is protected")
.message,
});
}, 2000);
})}
>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Title
</label>
<input
{...formInstance?.register("title")}
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add post title"
/>
{formInstance?.formState.errors?.title && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.title?.message}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Subtitle
</label>
<input
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add a subtitle"
{...formInstance?.register("subtitle")}
/>
{formInstance?.formState.errors?.subtitle && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.subtitle?.message}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Content
</label>
<textarea
cols={40}
rows={5}
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add content here"
{...formInstance?.register("content")}
></textarea>
{formInstance?.formState.errors?.content && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.content?.message}
</span>
)}
</div>
<div className="flex justify-between">
<button
disabled={!formInstance?.formState?.isValid}
type="submit"
className="w-40 disabled:bg-gray-300 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Create Post
</button>
</div>
</form>
</div>
</div>
);
}

export default App;

Notice, we have applied Zod validation rules in a PostSchema object and we have passed it to useForm()'s resolver configuration property.

Later inside the JSX, we have removed React Hook Form native validation rules from each form field:

<input
{...formInstance?.register("title")} // Field needs to be only registered, with no rules passed
type="text"
placeholder="Add post title"
/>

This is because, React Hook Form now relies on the Zod Resolver to handle validations.

In the sections below, we examine the Zod related concepts and explain them in snippets.

Zod with React Hook Form: How to Use an Object Schema

In the above changes, we have instantiated a PostSchema object with zod.object() method:

const PostSchema = zod.object({
title: zod.string().min(1, { message: "Title cannot be empty" }),
subtitle,
content,
});

An object schema typically represents a database entity with its properties. So, the fields here stand for the properties of the data entity.

We have to then pass this Zod schema to useForm() React Hook Form hook, to configure the value of the resolver option with the zodResolver() function:

const formInstance = useForm({
resolver: zodResolver(PostSchema),
// other config options
});

Adding the schema declaration object to useForm() with zodResolver() integrates it to React Hook Form's form instance. All Zod validations are run according to React Hook Form configurations, and errors are returned to the formInstance.

How to Declare Zod Validators

Notice, we have used a validator inside the PostSchema object.

title: zod.string().min(1, { message: "Title cannot be empty" }),

Zod validators help declare individual validation rules for form fields. They represent a single property in the database entity.

As you can see in the above title attribute, we can declare a validator inside an object schema.

We can declare field level schemas seprarately as well. As with subtitle and content:

const subtitle = zod.string().max(65, { message: "Keep subtitle shorter" });
const content = zod
.string()
.min(20, { message: "Content should have enough information" })
.max(1000, {
message: "Content has reached maximum limit of 1000 characters",
});

Notice, we can chain validators with their respectie methods. In subtitle and content, we have chained the min() and max() validators to string().

Zod Validator Syntax: Zod Primitives and Field Precision Validators

In the validator declarations above, we have used a string() primitive method that represents the TypeScript string primitive type. Primitives decide the TypeScript type of the form field entry. Zod has support for all TypeScript primitive types. A full list can be found here.

On top of primitives, we can impose field precision rules with specifiers such as min() and max(). The syntax for each validator starts with the primitive, followed by chained precision validators.

Notice that a precision validator follows an intuitive syntax. It takes the message in an object, after the specifier value:

.min(20, { message: "Content should have enough information" })
.max(1000, { message: "Content has reached maximum limit of 1000 characters" });

With the changes above, we get the same validations we implemented in the original code. We have effectively replaced Reach Hook Form validation rules with Zod schemas.

Notice that, other features of React Hook Form, such as server error integration in the handleSubmit callback we used on onSubmit event, remain unaffected:

typescript zod validator syntax

Zod Validation Rules Parsing

Zod parses validation rules according to configurations set in React Hook Form strategy and revalidation. By default, it invokes the parse() method on configured React Hook Form events. Parsing takes place thanks to the zodResolver which acts as middleman between Zod and hook.

Apart from that, validation runs can be triggered manually by calling safeParse() . The safeParse() method is called on the schema. It accepts the schema or form field data. If the data passes all validations, it returns an object with success: true and associated data. Otherwise, it returns success: false and the stack information without breaking the app:

PostSchema.safeParse({
title: "General Zod of Candor",
subtitle: "Executing...",
content: "Kneel brefore Kal El.",
}); // Returns { success: true, data: {...} }

PostSchema.safeParse({
title: "General Zod of Candor",
subtitle: "Executing... Running now and forever",
content: "Kneel",
}); // Returns { success: false, error: [...] }

We can use the Zod parse() method to trigger validations. However, parse() is not safe as it throws a ZodError that breaks the application. In such a case, we have to handle errors gracefully inside a try...catch block. You can find more information about parse() here.

Zod infer(): How to Infer Schema Types

Zod sets static types for all schema declared on the zod instance. The type for a schema can be produced with the infer<> method.

For example, we can store the static type for PostSchema like this:

type TPost = zod.infer<typeof PostSchema>;
/*
{
title: string;
subtitle: string;
content: string;
}
*/

TPost can then be used for annotating the post resource elsewhere.

Elaborate Zod Stuff with React Hook Form: An Edit Profile Example

In this section, we work with a form for <EditProfile /> component. While doing so, we explore some nuanced Zod schema features.

The code for the <EditProfile /> component looks like this:

Show `
` component code
./src/components/edit-profile.tsx
import { ReactNode, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as zod from "zod";

export function EditProfile() {
const first_name = zod
.string()
.min(1, { message: "First name cannot be empty" })
.max(50, { message: "Really? This long ?" })
.default("Dru");

const last_name = zod
.string()
.min(1, { message: "Last name cannot be empty" })
.max(50, { message: "Really? This long ?" })
.default("Zod");

const email = zod
.string()
.email()
.refine((e) => e.slice(e.length - 3).includes(".kr"), {
message: "This should be a Kryptonian email",
})
.default("general.zod@candor.mil.kr");

const website = email.transform((e) => `https://${e.split("@")?.[1]}`);
const ProfileSchema = zod.object({
username: zod
.string()
.transform((u) => u.split(" ").join("_"))
.default("general_zod"),
first_name,
last_name,
email,
});

const formInstance = useForm({
resolver: zodResolver(ProfileSchema),
mode: "onChange",
defaultValues: ProfileSchema.parse({}),
criteriaMode: "all",
shouldFocusError: true,
reValidateMode: "onSubmit",
});

useEffect(() => {
formInstance?.reset();
}, []);

return (
<div className="min-h-screen flex items-center justify-center w-full dark:bg-gray-950">
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg px-8 py-6 max-w-md">
<h1 className="text-2xl font-bold text-center mb-4 dark:text-gray-200">
Edit Profile
</h1>
<form
onSubmit={formInstance?.handleSubmit((data) => {
setTimeout(() => {
console.log("data", data);
}, 2000);
})}
>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username
</label>
<input
{...formInstance?.register("username")}
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Choose a username"
/>
{formInstance?.formState.errors?.username && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.username?.message as ReactNode}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
First Name
</label>
<input
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="First name"
{...formInstance?.register("first_name")}
/>
{formInstance?.formState.errors?.first_name && (
<span className="text-red-500 text-xs">
{
formInstance?.formState.errors?.first_name
?.message as ReactNode
}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Last Name
</label>
<input
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Last name"
{...formInstance?.register("last_name")}
/>
{formInstance?.formState.errors?.last_name && (
<span className="text-red-500 text-xs">
{
formInstance?.formState.errors?.last_name
?.message as ReactNode
}
</span>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email
</label>
<input
type="text"
className="shadow-sm rounded-md w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="email@exmaple.kr"
{...formInstance?.register("email")}
></input>
{formInstance?.formState.errors?.email && (
<span className="text-red-500 text-xs">
{formInstance?.formState.errors?.email?.message as ReactNode}
</span>
)}
</div>
<div className="flex justify-between">
<button
type="submit"
className="w-40 disabled:bg-gray-300 flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
);
}

You can import this component into App.tsx and then place it inside the JSX.

Let's now examine and discuss the Zod concepts implemented in this form.

Zod Default Values

In Zod, we have to specify default values on the validator itself, with the default() method. Like this:

const first_name = zod
.string()
.min(1, { message: "First name cannot be empty" })
.max(50, { message: "Really? This long ?" })
.default("Dru");

const last_name = zod
.string()
.min(1, { message: "Last name cannot be empty" })
.max(50, { message: "Really? This long ?" })
.default("Zod");

These, however, do not get relayed to React Hook Form's defaultValues attribute, so they do not get displayed on form fields.

For them to show up in the form fields, we have to parse the schema and then pass the output to useForm()'s defaultValues configuration:

const formInstance = useForm({
resolver: zodResolver(ProfileSchema),
mode: "onChange",
defaultValues: ProfileSchema.parse({}),
criteriaMode: "all",
shouldFocusError: true,
reValidateMode: "onSubmit",
});

With the empty object passed to ProfileSchema.parse({}), the default values specified with Zod defaultValue() before are set to their fields.

Zod Schema Derivation

Zod supports schema derivation and related type manipulations. For example, in cases where we need to set some properties to optional, we can apply the partial() method to derive a new type:

const ProfileOptional = ProfileSchema.partial();
const ProfileOptionalLastName = ProfileSchema.partial({
last_name: true,
});

type TProfileOptional = zod.infer<typeof ProfileOptional>;
/*
type TProfileOptional = {
username?: string | undefined;
first_name?: string | undefined;
last_name?: string | undefined;
email?: string | undefined;
};
*/

type TProfileOptionalLastName = zod.infer<typeof ProfileOptionalLastName>;
/*
type TProfileOptionalLastName = {
username: string;
first_name: string;
email: string;
last_name?: string | undefined;
};
*/

Notice, with no arguments passed, we applied partial flag to all items. We can make an individual field optional by passing setting it to true.

TIP

Zod schema derivation utilities are used to manipulate form schema. They mirror TypeScript utilities with similar names and produce static types that they represent in TypeScript.

For example, pick() produces a type with the same type transformation impact as TypeScript Pick<>. omit() brings the same type derivations as TypeScript Omit<>.

Zod Refinements

Zod refinements allow us to attain fine grained specificity in validation rules, which is not normally possible with primitives and field precision methods.

For example, we imposed our email field to be an email(). And we want it to be only a Kryptonian email, with last three characters being .kr:

const email = zod
.string()
.email()
.refine((e) => e.slice(e.length - 3).includes(".kr"), {
message: "This should be a Kryptonian email",
})
.default("general.zod@candor.mil.kr");
typescript zod refinements
TIP

The refine() method is a convenient method for implementing very specific validation rules. More verbose validators can be defined with the superRefine() method.

For details, see the docs here.

Zod Field Value Transformations

Zod transformations allow us to transform the value of a form field.

For example, in the username field, we want to convert &nbsp; (space) into an &lowbar; (underscore):

const ProfileSchema = zod.object({
username: zod
.string()
.transform((u) => u.split(" ").join("_"))
.default("general_zod"),
first_name,
last_name,
email,
});

In this snippet, we are transforming the input so that all spaces are replaced by an underscore. The transformed data is stored inside the formInstance. We have logged the form data to console inside the handleSubmit callback. You can verify this when you look at the console after submitting the form. The username field does not contain any space -- as all of them are turned into a lowbar:

{
"username": "general_dru_zod",
"first_name": "Dru",
"last_name": "Zod",
"email": "general.zod@candor.mil.kr"
}

As you can see, Zod refinements and transformations are useful for easily implementing subtle form requirements that are difficult to implement from scratch.

Summary

In this post, we learned about how to implement Zod schema validations in a React Hook Form based application.

We first explored what schema validations are, why we need them and how Zod provides battle tested solutions for type safe schemas in a React Hook application with Zod Resolver.

With an example of migrating existing Create Post React Hook Form based validations, we learned about how to declare Zod validators with string primitives and compose object schemas from them. We saw examples of using string related field precision validators such as min(), max() and email(). We also made sense of how Zod produces TypeScript types from a schema with infer() and how custom parsing is executed with the parse() and safeParse() methods.

Later on, we implemented subtle form features that are easily made possibly by Zod. We added default values the Zod way with the default() method. With refine() API, we implemented a custom validator that imposes an email() field to belong to .kr. Finally, we learned how the transform() method helps us convert a field data to something of our liking to be included in the form data set.