When creating a web application with Next.js, I added Zod and it was useful in a wide variety of ways, including validation for HTTP requests and form validation, so I’ll leave it as a memorandum.
Valibot, a later model, has the advantage of being smaller in size, but since it is not suitable for use in production or for long-term maintenance by myself, I chose Zod, which has a stable version.
Create the schema
Nothing will start without creating a schema, so I’ll touch on it briefly. Zod refers to everything from simple string types to complex nested object types as schemas.
I’m using the term “schema” to broadly refer to any data type, from a simple
stringto a complex nested object.
By creating a schema in this way, you can check whether an object satisfies the schema definition and create TypeScript types based on the schema. I won’t go into much detail about how to use this, but I think you can understand it by reading the documentation.
import { z } from "zod";
// define string schema
const SimpleSchema = z.string();
const ObjectSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
age: z.number().optional(),
});
Leverage the companion object pattern
When you define a schema in Zod, there are times when you want to use the schema, and similarly there are times when you want to use it as a TypeScript type definition. For example, if you define a User, Zod’s schema is UserSchema and the type information extracted from it is User, then you always have to give it two names, which tends to become redundant. TypeScript has the companion object pattern , which allows you to give a value and type the same name and use them interchangeably.
If you define it as shown below, it will be treated as such in places where it is treated as Zod Schema, and in places where it is treated as type information, which is a very convenient notation.
export const User = z.object({
id: z.string().uuid(),
name: z.string().min(5).max(16),
place: z.string().max(120).optional(),
});
export type User = z.infer<typeof User>;
Error message definition
Zod returns ZodError](https://zod.dev/ERROR_HANDLING?id=zoderror) if parse fails. It depends on the case, but when using React Hook Form, it is not often that you directly handle errors and display messages. However, the error message will display what Zod returned, so if you do nothing, it will be returned in English.
const name = z.string().min(10);
name.parse("cheetos");
// [
// { error: "String must contain at least 10 character(s)" },
// ]
I like English because it looks fashionable, but wholesalers won’t sell it, so I usually have to use Japanese. If you want to support multilingualization, there is a package such as zod-i18n, which is convenient, but it is a bit difficult to just translate it into Japanese, so we will look at another method.
The first method is to pass it as an argument when defining the schema. If you define the error message as shown below, things will work fine. Also, when using methods such as refine, you can define free error messages, so this is a very useful method.
const name = z
.string({ invalid_type_error: "Invalid type" })
.min(10, { message: "Must be at least 10 characters" });
name.parse("cheetos");
// [
// { error: "Must be at least 10 characters" },
// ]
The second method is to define a ZodErrorMap and pass it to Zod. This method eliminates the need to pass arguments many times when defining a schema, making the code easier to write and more readable.
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
/**...*/
};
z.setErrorMap(customErrorMap);
It is very difficult to write customErrorMap here using only type information, so I think it will work better if you write it based on the Zod source code ](https://github.com/colinhacks/zod/blob/master/src/locales/en.ts).
Write the test
Tests are not essential if you are defining a schema using Zod’s standard functions, but you will want to write tests when you use regex, transform, refine, etc.
It is in the Zod sample, but I will describe it based on whether the password input matches.
export const PasswordForm = z
.object({
password: z.string(),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"], // path of error
});
Assuming you have a schema like the one above, you might want to test whether an error is thrown when parsed. If comparing error instances is redundant and difficult to write, you may be able to compare only messages.
describe("PasswordForm", () => {
it("should parse", () => {
const data = {
password: "1q2w3e",
confirm: "1q2w3e",
};
expect(() => PasswordForm.parse(data)).not.toThrow();
});
it("should throw error if password does not match", () => {
const data = {
password: "1q2w3e",
confirm: "e3w2q1",
};
// expect(() => PasswordForm.parse(data)).toThrow("Passwords don't match");
expect(() => PasswordForm.parse(data)).toThrow(
new ZodError([
{
code: "custom",
message: "Passwords don't match",
path: ["confirm"],
},
]),
);
});
});
If it is troublesome to define data one by one, you can save time by using zod-mock. This library generates data using @faker-js/faker based on Zod’s schema. If it doesn’t matter what data is entered when creating the object, you can simply use the function generateMock. However, as in this case, password and confirm need to be the same, or even if you use a method such as string().startsWith(), the default is just a random string generated by faker, which causes parsing to fail. In that case, defining stringMap will work fine. If you use this for the startsWith mentioned earlier and make it a prefix + random character string, there will be no problem.
const data = generateMock(PasswordForm, {
stringMap: {
password: () => "1q2w3e",
confirm: () => "1q2w3e",
},
});
Also, I actually use it for testing, but more than that, it is very useful when generating responses such as MSW, which allows me to return random responses. This is especially useful because it takes a lot of effort to hard-code array-type responses, change them, and check them.
Validate the request with Next.js
We have defined Zod schemas, and we will use them to verify requests such as Next.js’s getServerSideProps. Since the query parameters are stored in req.query of the function, you can use Zod to handle all of them, making verification quite easy.
For example, if you have query, limit, and offset on the search page, and they are optional, you can make them safe by writing them in one way.
const Query = z.object({
query: z.coerce.string().optional(),
limit: z.coerce.number().int().min(1).optional().catch(undefined),
offset: z.coerce.number().int().positive().optional().catch(undefined),
});
Even though the original type of each is string | string[] | undefined, the type will be converted just by parsing it.
Please note that the behavior changes between string and number only when converting string[]. In the case of string, if the array length is 2 or more, a string containing each element separated by commas is returned. On the other hand, with number, if the array length exceeds 2, an error will occur because NaN is passed. However, in this schema, catch is assigned in the method chain, so no error occurs and undefined is returned. If you don’t like this behavior, it might be a good idea to use Preprocess instead of coerce.
Combine with React Hook Form
You can validate form values by using the React Hook Form body and @hookform/resolvers. You can use it by simply passing the schema type to useForm and specifying the resolver. If you do the following, all you have to do is pass the props expanded with ...methods.register("username") to the input element and it will do everything from registration to validation.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
const CreateUser = z.object({
username: z.string().min(5),
profile: z.string().optional(),
age: z.number().int().optional(),
zip: z.string(),
agreement: z.literal(true),
});
const methods = useForm<z.infer<typeof CreateUser>>({
defaultValues: undefined,
mode: "onBlur",
reValidateMode: "onChange",
resolver: zodResolver(CreateUser),
});
As you might imagine if you are always worried about input elements, some types of conversions received by input may not work as expected. I will list them and explain them a little.
Entering numbers and dates
First, if you want to receive a numeric type from the input element, in the previous example, in the case of age, a string type is passed to Zod, so you need to convert it. It looks like it can be easily converted using coerce, but this method interprets null and "" as 0, so it cannot be used if 0 can be input. In that case, you have no choice but to implement the processing yourself using preprocess… A simple example is shown below, but if you play the ones that are likely to be interpreted as 0 first, the validation will pass smoothly.
const CreateUser = z.object({
age: z.preprocess((value) => {
if (!value) {
return undefined;
}
return Number(value);
}, z.number().int().optional()),
});
This will prevent users who did not enter their age from being turned into babies. I also introduced that there is a method called catch, but if you use this method, error handling will be performed and the error message will not be displayed, so be careful as it may be a bad idea in some cases.
Even when inputting a date using type="date", an error occurs when performing validation because empty characters are not interpreted properly. So, similarly, it would be best to return undefined if preprocess is unnecessary, but otherwise return Date.
Checkbox input
The checkbox value is a Boolean value when passed to Zod, so you can leave it as is, but there may be cases where you only want to allow true, for example when asking for consent to a privacy policy. In that case, use Literals. As the name suggests, it is a schema that only accepts certain values, so an error will occur if it is not true.
const PrivacyPolicy = z.literal(true);
Also, if you want to use Boolean values, such as when subscribing to an email newsletter, use Booleans.
const Subscription = z.boolean();
File input
Since the type that handles FileList is not standard in Zod, use Custom schemas](https://zod.dev/?id=custom-schemas). Although extracting a File from a FileList is not essential, it is very useful when dealing with a single file, so I use it. On top of that, if you need validation such as file size, you can define it with refine as shown below.
const UserIcon = z
.custom<FileList>()
.transform((files) => files[0])
.optional()
.refine((file) => !file || file.size < 3_000_000, "maximum size exceeded")
.refine(
(file) => !file || ["image/png"].includes(file.type),
"invalid extension",
);
It is also possible to test these contents, but if you do it without preparing images, it may slow down the test, so it is important to be concerned about performance. The image looks like this.
test("sample", () => {
const data = new Uint8Array(10 * 1024 * 1024);
data.fill(0);
const blob = new Blob([data], { type: "text/plain" });
const file = new File([blob], "icon.jpeg", {
type: "image/png",
});
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("name", "file-upload");
input.multiple = true;
const mockFileList = Object.create(input.files);
mockFileList[0] = file;
expect(() => UserIcon.parse(mockFileList)).toThrow("maximum size exceeded");
});
Type conversion
For example, if you want to receive the last name and first name on the form, but want to send them in a connected state when making a POST request, use [transform](https://zod.dev/?id=transform) to convert.
const CreateUser = z
.object({
firstName: z.string(),
lastName: z.string(),
zip: z.string().optional(),
})
.transform(({ firstName, lastName, ...data }) => ({
name: `${lastName} ${firstName}`,
...data,
}));
At this time, the type inferred by z.infer becomes {name: string, zip: string | undefined}, which makes the type passed to useForm inappropriate. By passing z.input instead, you can convey type information before conversion processing.
const {
handleSubmit,
register,
formState: { errors },
} = useForm<z.input<typeof CreateUser>>({
defaultValues: undefined,
mode: "onBlur",
reValidateMode: "onChange",
resolver: zodResolver(CreateUser),
});
However, in React Hook Form v7, although the argument of handleSubmit is correctly z.output<typeof CreateUser>, the type information is z.input<typeof CreateUser>, so you need to convert it as shown below (issue).
<form
onSubmit={handleSubmit((_data) => {
const data = _data as unknown as z.output<typeof CreateUser>;
console.log(data);
})}
>
Even when converting by itself, it fully demonstrates its power. For example, you can easily use it to accept postal codes with hyphens, but want to leave them without hyphens when actually submitting a request.
const Zip = z.preprocess(
(value) => {
if (!value) {
return undefined;
}
return String(value);
},
z
.string()
.regex(/^\d{7}$/)
.or(z.string().regex(/^[0-9]{3}-[0-9]{4}$/))
.optional()
.transform((value) => value?.replace("-", "")),
);
However, if you want to do something with each other in the HTML input element, for example, if you have two fields, “Price” and “Price including tax”, and you want to update the value of the other based on the input in one, Zod cannot handle this, so in this case, you can handle it by using onChange and deps with the register function of React Hook Form.
Execute validation with Server Actions
Let’s try implementing it in Next.js to see how it works when combined with React’s Server Actions. [Quote from Next.js
Server Components support progressive enhancement by default, meaning the form will be submitted even if JavaScript hasn’t loaded yet or is disabled. In Client Components, forms invoking Server Actions will queue submissions if JavaScript isn’t loaded yet, prioritizing client hydration.
In order to enjoy this benefit, it seems better to perform validation in Server Actions instead of performing it interactively in a client component.
First, let’s review how to handle Server Actions. Basically, the "use server" directive is given, FormData is received, and the necessary processing is performed. They can be defined in the same file as long as they define Server Components. If you want to separate processing at the file level or use it with Client Components, you can define it in a separate file.
// actions.ts
"use server";
export const createUser = async (formData: FormData) => {
const data = Object.fromEntries(formData.entries());
console.log(data);
};
All you have to do is call this on the component side. It’s very simple.
import { createUser } from "./actions";
export const FormComponent = () => {
return <form action={createUser} />;
};
Next, we will look at how to perform validation using Server Actions and notify the component side of the results. By using the Hook called useFormState, you can subscribe to a self-defined state on the component side via the return value of Action. So in this case, you can perform validation as follows.
- Enable useFormState to receive state from Action
- If a validation error occurs in Action, return an error
- Check the state returned by useFormState and display the error
The response on the component side is simple, just change it to pass the return value of useFormState instead of passing the action directly and it will work.
import { useFormState } from "react-dom";
import { createUser } from "./actions";
export const Form = () => {
const [state, action] = useFormState(createUser, {});
return (
<form action={action} className="flex flex-col gap-6 w-full">
{/** some inputs */}
</form>
)
For Actions, the arguments and return value types change drastically, so let’s take a look at them.
"use server";
import { z } from "zod";
const CreateUser = z.object({
username: z.string().regex(/^[a-zA-Z_\d]{6,10}/, {
message: "should be ^[a-zA-Z_d]{6,10}",
}),
});
type CreateUser = z.infer<typeof CreateUser>;
type CreateUserState = {
errors?: z.inferFlattenedErrors<typeof CreateUser>["fieldErrors"];
};
/**
* Server Action Code
*/
export const createUser = async (
prevState: CreateUserState,
formData: FormData,
): Promise<CreateUserState> => {
const data = Object.fromEntries(formData.entries());
const validated = CreateUser.safeParse(data);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors,
};
}
// do API request...
return {};
};
The type definition is confusing, but let’s take a look at the Server Actions interface. The first argument will be replaced with the state when it was fired last time, and the second argument will be replaced with FormData. Then, change the return type to the state type as well (promise is attached since it is an asynchronous function).
What the above type definition does is that Zod has a method called flatten that makes it easier to handle errors that occur in the schema. In this schema, it will be handled as follows.
// zod schema type
type CreateUser = {
username: string;
};
// zod schema errors type
type CreateUserFlattenFieldErrors = {
username?: string[] | undefined;
};
This form is easy enough to handle, so I defined the type at the beginning in order to use it as the formState type. If you want to handle errors in API requests other than validation errors, you can extend the case of CreateUserState and add keys such as message.
Now that we have defined the Action, we will change it to display the error on the component side.
The type of state is passed from the type information of createUser by useFormState, so it can be used on the client side without being aware of anything. You can also handle this very easily by branching to display if there is an error.
export const Form = () => {
const [state, action] = useFormState(createUser, {});
return (
<form action={action}>
<label>
<p>username</p>
<input id="username" name="username" />
<p>{state.errors?.username}</p>
</label>
<button type="submit" />
</form>
);
};
Get the execution status of Action with useFormStatus
Since the communication is via form, in most cases, asynchronous processing is performed on the Actions side, which takes time. You can use useFormStatus to get the status of the Action you are using.
This is useful, for example, if you want to make the submit button unavailable while the Action is running.
Since it is a Hook with no arguments, it is easy to use, but as shown in the example below, you need to make the component that executes useFormStatus (in this case a button) a child of the component that defines the Form.
import { useFormState, useFormStatus } from "react-dom";
import { createUser } from "./actions";
const SubmitButton = () => {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
>
submit
</button>
);
}
export const Form = () => {
const [state, action] = useFormState(createUser, {});
return (
<form action={action} className="flex flex-col gap-6 w-full">
{/** some inputs */}
<SubmitButton />
</form>
)
This way, even if there is a process that takes time on the action side, you will not have to press the button again.
Conclusion
If a schema is required, it can be assembled around Zod to support a wide variety of use cases, and I felt the strength of the Zod ecosystem itself. I will definitely continue to use it if I have the opportunity.