Published at
Whether you are a front-end developer or a back-end developer, you probably have faced difficulties with validating forms, sometimes you are writing too many if
else
statements that will make your code bloated and hard to maintain.
The best way to handle form validations throughout your code is definitely not by writing lines and lines of control flow statements, but to use a library that does all of that for us.
There are many libraries out there that you can use, but in this blog post, I will be using Zod along with React.js to do all of the form validations from the front end and show the errors back to the user.
Also, we will be using Zod again to validate the same form from the back which we will be writing using Node.js and Express.js.
Zod is a TypeScript-first schema declaration and validation library. Zod can be used for any validation, from a simple string type to a very complex nested object.
The best thing about Zod is that it is very user-friendly and easy to use, it helps avoid duplicate declarations.
You declare the validator and Zod will infer the static type in TypeScript.
For example, you have a simple schema written in Zod
const User = z.object({
name: z.string(),
email: z.string().email(),
})
You can get the TypeScript type by writing the following code
type UserType = z.infer<typeof User>
It will generate the following TypeScript type
type UserType = {
name: string;
email: string;
}
Some other great aspects of Zod:
.optional()
) return a new instanceSo let’s get started with some form validations with Zod.
For the front end, we will be creating a simple React application and have a form in it, we will be validating the form by using Zod and showing the errors back to the user if there are any.
Let’s create a simple React application using Vite.
Vite is a rapid development tool for modern web projects. It focuses on speed and performance by improving the development experience. Vite uses native browser ES imports to enable support for modern browsers without a build process.
Run the following command to create a Vite project
yarn create vite
After you run the command, it will ask you to name the project.
I will name it “react-zod”.
Vite create provide project name
After that select your preferred front-end framework
Vite React
After that select your programming language, and I will choose TypeScript.
Zod does not require you to use TypeScript, so if you want to go ahead with JavaScript it is totally fine.
Vite.js React.js TypeScript project
After that, your application will be created and you can cd
into it and install the dependencies by running yarn
in your terminal.
I will also install TailwindCSS with React, which makes writing CSS code much easier, follow this simple tutorial to install TailwindCSS in your React project.
I will create a sample form that contains some different data types just for the tutorial.
This is our first and only page of the React application, with this form we will test and validate all of our inputs.
import { ChangeEvent, FormEventHandler, useState } from "react"
function App() {
const [data, setData] = useState<any>({})
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
}
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
if (name === "isAdmin") {
setData((prev: any) => ({ ...prev, [name]: !prev.isAdmin }))
} else {
setData((prev: any) => ({ ...prev, [name]: value }))
}
}
return (
<div className="w-full max-w-5xl p-5 mx-auto">
<h1 className="text-center text-3xl font-bold">
React Form validation with ZOD
</h1>
<form
onSubmit={onSubmit}
className="mt-10 max-w-sm mx-auto flex flex-col gap-2"
>
<div>
<label className="mb-1">Name</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Name"
type="text"
name="name"
value={data.name}
onChange={onChange}
/>
</div>
<div>
<label className="mb-1">Email Address</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Email Address"
type="text"
name="email"
value={data.email}
onChange={onChange}
/>
</div>
<div>
<label className="mb-1">Password</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Password"
type="password"
name="password"
value={data.password}
onChange={onChange}
/>
</div>
<div>
<label className="mb-1">Percentage</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Percentage"
type="text"
name="percentage"
value={data.percentage}
onChange={(e) => {
const value = Number(e.target.value)
setData((p: any) => ({ ...p, percentage: value }))
}}
/>
</div>
<div>
<label className="mb-1">Is Admin</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Boolean"
type="checkbox"
name="isAdmin"
checked={data.isAdmin}
onChange={onChange}
/>
</div>
<button
type="submit"
className="py-2 px-5 rounded-lg border hover:bg-gray-50"
>
Submit
</button>
</form>
</div>
)
}
export default App
The code above is a simple React component that includes a simple form inside of it which has a few fields that are name
, email
, password
, percentage
, isAdmin
each with a different data type.
The goal is to validate any data that will be filled by any user and only submit the form when all of the fields pass all of the form validations that will be taken care of by Zod.
We have a onChange
function that will be triggered whenever each input has a change in its value
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
if (name === "isAdmin") {
setData((prev: any) => ({ ...prev, [name]: !prev.isAdmin }))
} else {
setData((prev: any) => ({ ...prev, [name]: value }))
}
}
We are also checking if the name is equal to isAdmin
in that case we are changing the state to the opposite value of the previous value to get a Boolean value.
The onSumbit
button is an empty function which only has a simple function inside of it which is the e.preventDefault()
which will prevent the page to reload. We will come back to this function later to handle the submit form action.
This is how the application looks like
Install Zod by running the following command in the terminal
yarn add zod
Important: If you are using TypeScript, it is required that you have TypeScript version 4.1
or above installed on your machine, if you haven’t make sure you install the latest version by running the following command
yarn global add typescript@latest
Also it is important to set your strict
value as true
in your tsconfig.json
file.
Now it’s time to create a schema using Zod.
First, let's create a folder and store all of our schemas inside of it.
Create a folder to store all of your schemas inside of your src
directory, I will name the folder schemas
Create a schema type for the form above inside of that folder, src/schemas/form.ts
This is how the folder structure looks like
Now we can use zod
by importing it.
import { z } from "zod"
import { z } from "zod"
export const Form = z.object({
name: z.string(),
email: z.string().email(),
password: z.string(),
percentage: z.number(),
isAdmin: z.boolean(),
})
As you can see, we are using the object
property of zod
to tell it that our Form
object has some specific properties with specific types.
name: z.string()
makes sure that name is always string
email: z.email()
makes sure that the email field has a valid e-mail address.
percentage: z.number()
makes sure that the percentage is a valid number.
isAdmin: z.boolean()
makes sure that the field contains a boolean.
But this is not all, there are so many other things we can do with zod
like making sure a certain property has a specific length, or a number is between a specific range.
We can also add custom error messages when a particular field fails validation, for example, if we want the name
to have a minimum length of 2 and a maximum length of 32, we can do something like this
const name = z
.string()
.min(2, "Name must be at least 2 characters")
.max(32, "Name must be at most 32 characters")
Let’s make our form much better by adding more validations to it.
import { z } from "zod"
export const Form = z.object({
name: z
.string({ description: "Your name" })
.min(2, "Name must be at least 2 characters")
.max(32, "Name must be at most 32 characters")
.optional(),
email: z.string().email(),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(64, "Password must be at most 64 characters"),
percentage: z
.number()
.min(0, "Percentage must be at least 0")
.max(100, "Percentage must be at most 100"),
isAdmin: z.boolean().default(false),
})
By default, all of the values are expected to be required
, meaning they should be defined and have a value of their specific type when any data is parsed to the schema. We are using the optional()
method on the name
property to make it an optional property.
We can create a TypeScript type directly from our Zod schema just by using the infer
method.
export type FormType = z.infer<typeof Form>
This will create the following TypeScript type
type FormType = {
name?: string | undefined;
email: string;
password: string;
percentage: number;
isAdmin: boolean;
}
As you can see, the name
property is optional in the TypeScript type as well.
Now that our schema is ready to be used, let’s write a function to validate the React form by using Zod.
Go to your App.tsx
file and import the Form
schema that we just created.
import { Form } from "./schemas/form"
Create a function inside of the App
component with the name validate
to handle the form validation, here is how the validate
function looks like
const validate = () => {
const formData = Form.safeParse(data)
if (!formData.success) {
const { issues } = formData.error
setErrors(issues)
} else {
setErrors([])
}
}
In this function, we are using the safeParse
method on the Form
object. which will return a property with the name success
which is a boolean
if the value is true
it means that the validation was not successful otherwise it is successful.
After the function creation, we can easily call it whenever the form is submitted
const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault()
validate()
}
When the validation is not successful or the success
value equals false
then another property will be returned from the safeParse
method which is the error
object which also has another property on it with the name issues
which is exactly what we need.
The issues
property is an array of objects with the following schema
{
code: "invalid_type",
expected: "string",
message: "Required",
path: ["email"],
received: "undefined",
}
What we need in this object is the message
property, we can easily access this message
property and use it to show it back to the user so that they will understand why the validation failed.
This message
property can also be easily configured when the Zod schema is initialized, for example,
const User = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'), // will show this error if "name" property has a length of less than 2 characters
email: z.string().email(),
})
Back to our validate
function, we are also running a function with the name setErrors
which updates the state errors
which were defined above by using a simple useState
hook.
const [errors, setErrors] = useState([])
If you want to make this type-safe, you can use the ZodIssue
type which comes with Zod which can be imported easily
import { ZodIssue } from "zod"
Updating the state
const [errors, setErrors] = useState<ZodIssue[]>([])
Now that we have written our validate
function, if there is any error when the user submits the form, it will update the errors
state with the new errors.
We can use this state to show it back to the user.
I will create a new function with the name getError
which will get one argument path
and return the error message in the errors
state.
const getError = (path: string) => {
const error = errors.find((error) => error.path === path)
return error ? (
<small className="text-red-500">{error?.message}</small>
) : null
}
As you can see we are returning a JSX
element <small>
with a red colour.
We can run this function under every input element in our Form component.
This is how our Form component looks like
<form
onSubmit={onSubmit}
className="mt-10 max-w-sm mx-auto flex flex-col gap-2"
>
<div>
<label className="mb-1">Name</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Name"
type="text"
name="name"
value={data.name}
onChange={onChange}
/>
{getError("name")}
</div>
<div>
<label className="mb-1">Email Address</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Email Address"
type="text"
name="email"
value={data.email}
onChange={onChange}
/>
{getError("email")}
</div>
<div>
<label className="mb-1">Password</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Password"
type="password"
name="password"
value={data.password}
onChange={onChange}
/>
{getError("password")}
</div>
<div>
<label className="mb-1">Percentage</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Percentage"
type="text"
name="percentage"
value={data.percentage}
onChange={(e) => {
const value = Number(e.target.value)
setData((p: any) => ({ ...p, percentage: value }))
}}
/>
{getError("percentage")}
</div>
<div>
<label className="mb-1">Is Admin</label>
<input
className="px-5 py-2 outline-none border border-gray-300 w-full rounded-md"
placeholder="Boolean"
type="checkbox"
name="isAdmin"
checked={data.isAdmin}
onChange={onChange}
/>
{getError("isAdmin")}
</div>
<button
type="submit"
className="py-2 px-5 rounded-lg border hover:bg-gray-50"
>
Submit
</button>
</form>
Now it is time to try out our application and see if it works as expected.
First, let's submit the form with empty values.
As you can see we get required
errors for objects that didn’t have the optional()
method run on.
Let’s add data to the fields
Now we no longer get the required
error messages, but we get other validation messages that we have defined above.
If we fill out the form without any mistakes, we no longer will see the validation errors.
Congratulations, now your form is validated with as little effort as possible, thanks to Zod there is no longer the need to write lines and lines of if
and else
cases and other control flow statements to validate your forms.
let’s create a simple Node.js backend by using express.js
to validate the same form that we have just created, but this time from the backend and sending back the proper error messages to the client.
This is how our simple node.js server code looks like
const express = require('express')
const app = express()
app.use(express.json())
app.post('/form/submit', (req, res) => {})
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})
Let’s update the POST
route with the proper code to validate the same data in the form that we created in the front end.
First things first, let’s create the Zod schema for the form validations.
const Form = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(32, 'Name must be at most 32 characters')
.optional(),
email: z.string({ required_error: 'Email is required' }).email(),
password: z
.string({ required_error: 'Password is required' })
.min(8, 'Password must be at least 8 characters')
.max(64, 'Password must be at most 64 characters'),
me: z.string({ required_error: 'You must provide me' }),
percentage: z
.number({ required_error: 'Percentage is required' })
.min(0, 'Percentage must be at least 0')
.max(100, 'Percentage must be at most 100'),
isAdmin: z.boolean().default(false)
})
This one is a bit different, when we are initializing a type of a property like z.string()
we are also passing in the options and setting the required_error
property, this way were are able to overwrite the default required error message.
Now that our schema is ready to use, let’s create a POST
route with express and validate the input data with Zod.
app.post('/form/submit', (req, res) => {
try {
const body = Form.safeParse(req.body)
if (!body.success) {
res.status(400).json({ message: body.error.issues[0].message })
return
}
// Do something with the data
res.json({
message: 'Form submitted successfully'
})
} catch (error) {
res.json({
message: error.message
})
}
})
Of course, you can send a different response back to the client if there is an issue in validating the form, but for this tutorial, I will be sending back the first issue message.
Let’s test our route with various data and see if it works as expected
First test
// request
{
"name": "John",
"email": "test",
"password": "123456",
"isAdmin": "true"
}
// response
{
"message": "Invalid email"
}
Second test
// request
{
"name": "John",
"email": "test@gmail.com",
"password": "123456",
"isAdmin": "true"
}
// response
{
"message": "Password must be at least 8 characters"
}
Third test
// request
{
"name": "John",
"email": "test@gmail.com",
"password": "12345645",
"isAdmin": "true"
}
// response
{
"message": "Percentage is required"
}
Fourth test
// request
{
"name": "John",
"email": "test@gmail.com",
"password": "12345645",
"isAdmin": "true",
"percentage": 3
}
// response
{
"message": "Expected boolean, received string"
}
Final test
// request
{
"name": "John",
"email": "test@gmail.com",
"password": "12345645",
"isAdmin": true,
"percentage": 3
}
// response
{
"message": "Form submitted successfully"
}
Congratulations, now your form is validated strongly from the back end as well, without having to do too many configurations and writing endless lines of code which will make your code extremely bloated and making it more difficult to maintain.
Since Zod works perfectly well with TypeScript, it will make your code type-safe leaving no space for unexpected errors and bugs.
Thanks for reading this blog post, if you enjoyed the blog, make sure you share it with other developers so that they can benefit from it as well.
If you have any notes, please let me know from the comments.
Saifullah Rahman
March 29, 2023
Thanks a lot!
That was really helpful