Editor’s note: This article was last updated on 8 March 2023 to add a section about adding a custom validation with Zod.
TypeScript is awesome! It has improved developer productivity and tooling over recent years. TypeScript not only helps with static type checking, but also has added a set of object-oriented programming (OOP) concepts such as generics, modules, classes, interfaces, and more.
Arguably, going back to a JavaScript-only codebase can be difficult if you have worked with TypeScript. Although TypeScript looks great in all aspects, it has a blind spot — it only does static type checking at compile time and doesn’t have any runtime checks at all.
That’s where Zod comes in. In this article, you will learn about schema design and validation in Zod and how to run it in a TypeScript codebase at runtime:
- The importance of schema validation
- What is Zod and why do we need it?
- Why use Zod over other tools?
- Benefits of using Zod with TypeScript
- Primitives in Zod
- Objects in Zod
- Refinements in Zod
- Composing complex schema objects
- Type inferences in Zod
- Adding a custom validation with Zod
The importance of schema validation
Before we even begin to understand how Zod works, it’s important to know why we need schema validation in the first place.
Schema validation provides assurance that data is strictly similar to a set of patterns, structures, and data types you have provided. It helps identify quality issues earlier in your codebase and prevents errors that arise from incomplete or incorrect data types. Having a robust schema validation can not only improve performance but you are likely less prone to errors while building production-ready large-scale applications.
What is Zod and why do we need it?
You might be asking yourself, why would someone need a runtime check in the first place?
Well, runtime checks help with getting correctly validated data on the server side. In a case where the user is filling out some kind of form, TypeScript doesn’t know if the user inputs are as good as you expect them to be on the server at runtime.
Therefore, Zod helps with data integrity and prevents sending out garbage values to the database. Also, it’s better to log an error on the UI itself, like in cases where a user types in numbers when you expect a string.
Zod is a tool that solves this exact problem. It fills this TypeScript blindspot and helps with type safety during runtime. Zod can help you build a pretty flexible schema design and run it against a form or user input.
Why use Zod over other tools?
There are already a couple of tools such as Yup, Joi, io-ts, and more that do similar things to Zod. While all these libraries are great for schema designing and doing runtime validation in general, Zod outshines itself in several factors such as:
- Flexibility: Zod is very flexible and can chain several instances together, thereby preventing duplication of static types
- It has no dependencies
- Although it is best to use Zod along with TypeScript, if you want a limited, Zod-only type safety, you can use it in plain JavaScript projects as well. It is not TypeScript dependent
- It focuses on immutability, therefore some methods like
optional()
return a new instance instead of mutating the same object altogether
Benefits of using Zod with TypeScript
Zod works particularly well with TypeScript. In a typical TypeScript codebase, you ensure that all the static type safety will be handled by TypeScript at compile time. Even most of the third-party packages ship their code along with their types
, like how React has its types under an npm package called @types/react
.
Having an additional runtime check using Zod fixes these issues with TypeScript. You might have encountered issues where you need to assign undefined
or unknown
to a type even after using its corresponding @types
package — the sole reason being you don’t know beforehand what a user would enter or what the response structure is like.
Zod applies these runtime checks in a very concise way. That is literally like taking data types from TypeScript and extending them to Zod-specific methods. Zod automatically infers the type that you have already mentioned in TypeScript, preventing type duplication.
These kinds of chainable Zod utility methods have inspired TypeScript utilities as well, proving how well these two go along together.
Additionally, there are situations where TypeScript’s error handling would be suboptimal and Zod could do a better job displaying the error at runtime, based on a user’s interaction with the UI.
Primitives in Zod
Let’s start talking about schema validation with a very basic Zod example:
import { z } from "zod" const dataInputFromUser = z.string().min(8).max(16) dataInputFromUser.parse("A long text")
The above code will safely parse. You can navigate the user to the next input or a page, depending on your use case.
And if we tweak the line a bit, like this:
dataInputFromUser.parse("A really long text")
It’ll throw an exception:
errors: [ { code: 'too_big', maximum: 16, type: 'string', inclusive: true, message: 'String must contain at most 16 character(s)', path: [] } ]
If you need safer exception handling, you can simply log the error using the .safeParse()
method.
This is one of the simplest examples to use primitives in Zod. Primitive values are not just limited to string
, but provide other methods such as number
, bigint
, boolean
, and date
. There are a couple of empty types as well, like undefined
, null
, and void
.
Utilizing these primitives along with a few specific methods can lead to a very flexible schema design:
// email validation // z.string().email().startsWith(string).trim().max(18).min(1) // can be used for Terms & Conditions check at runtime // z.boolean() // can be used for high-precision large values if they are being calculated at runtime // z.bigint()
The example above is chaining these primitives together to create a very functional type safety at runtime for an email input field.
Objects in Zod
Most of the user-facing form requires a couple of data inputs and validation of varying data types. This is where it’s better to use objects in Zod. You can create a schema for a set of properties you want at a runtime check:
import { z } from 'zod' const FormData = z.object({ firstName: z.string().min(1).max(18), lastName: z.string().min(1).max(18), phone: z.string().min(10).max(14).optional(), email: z.string().email(), url: z.string().url().optional(), }); const validateFormData = (inputs: unknown) => { const isValidData = FormData.parse(inputs); return isValidData; };
In the above TypeScript code, there is no way to enforce schema validation at runtime using TypeScript only, hence the inputs: unknown
.
This is where z.Object()
can be used for building an extensible schema validation. The data will be safely parsed if a user enters the fields that satisfy your schema definition. You’ll then be able to send that data to the server. Otherwise, an exception will be thrown, as we saw in the beginning.
Refinements in Zod
Zod allows you to pass a custom validator using an API called refinements
. You might need to create your own custom validations in scenarios where you need to club, for example, error messages together, codes, etc.
More great articles from LogRocket:
- Don't miss a moment with The Replay, a curated newsletter from LogRocket
- Learn how LogRocket's Galileo cuts through the noise to proactively resolve issues in your app
- Use React's useEffect to optimize your application's performance
- Switch between multiple versions of Node
- Discover how to use the React children prop with TypeScript
- Explore creating a custom mouse cursor with CSS
- Advisory boards aren’t just for executives. Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Zod’s refinements provide
two APIs to achieve this:
refine
: refine is an API that takes in two arguments, the first being a function and the second argument accepting a number of configuration options (in special cases, the second argument can be a function as well). You can use refine
to custom validate the length of the string as in the following case :
const userBio = z.string().refine((i) => i.length <= 255, { // overrides the error message here message: "Your bio cannot be more than 255 characters", });
In the case where your function returns a promise, refine
can be asynchronous too:
const isSubscribed = z.string().refine(async (i) => { // async operation that takes some time to return return true; });
superRefine
: Similar to refine
, superRefine
also helps build custom validation in Zod. According to its documentation, superRefine
is now verbose and you should ideally use refine
instead. refine
was actually built on top of the superRefine
API and thus has a more robust API for modern use. superRefine
takes in two arguments.
In the example below, you can check if the name
array is bigger than 15 items. If it’s the case, then you can conditionally add error messages with the help of the chainable addIssue
method:
const names = z.array(z.string()).superRefine((i, context) => { if (i.length > 15) { context.addIssue({ maximum: 15, message: "More than 15 names not allowed", }); } });
Composing complex schema objects
There could be such cases where the schema object design is nearly the same for a couple of forms/fields, apart from a few extra or missing types. In those situations, you don’t need to keep duplicating the same object schema over and over again. Instead, you can prevent duplication by utilizing Zod methods such as merge()
and extend()
.
The code above can be further improved by removing the duplication for firstName
and lastName
types, like so:
const GenericStringContraint = z.string().min(1).max(18), const FormData = z.object({ firstName: GenericStringContraint, lastName: GenericStringContraint, // ... });
This is similar to a chunk of data types being repeated in a couple of forms. Say, for example, userId
and fullName
get repeated in Article
and UserProfile
schema definitions, then we can simply “extend” these two:
const UserData = z.object({ userId: z.string().min(1).max(5), fullName : z.string().min(1).max(18), }); const Article = UserData.extend({ title: z.string().min(5), date: z.date() }); const UserProfile = UserData.extend({ isVerifield: z.boolean(), numberOfArticles: z.number().positive() });
The above approach can be a better way to have maintainable code and avoid data duplication. One thing to be mindful of while using extend()
is that it mutates the schema objects, i.e., overwrites them.
Very similar to the extend()
method, Zod provides a merge()
method as well, which differs slightly. It is useful while merging two schema objects, not necessarily “extending” them:
import { z } from "zod" const User = z.object({ url: z.string().email().min(8), name: z.string(), age: z.number().min(2).max(3).optional() }) const Skill = z.object({ title: z.string().min(1), }) const SkilledUser = User.merge(Skill) const Data = SkilledUser.parse({ url: "[email protected]", name: "Nancy", age: 21, title: 'water surfing', }) console.log(Data) // parses successfully
As you can see in the code above, the title
data field has been merged to the User
schema object, hence why it’d be parsed safely by Zod. In a case where the title
field is a number, say 20
, Zod would throw an error mentioning that title
is constrained to the type of z.string().min(1)
only.
Zod’s merge()
property doesn’t rewrite the fields as it is in the case with extends()
.
Type inferences in Zod
Suppose you already have your type defined somewhere and you want a newly created variable to deduce its type from existing ones. In that case, Zod has a method that can infer its type:
let fullName = z.string(); type fullName = z.infer<typeof fullName> // string const userAge: fullName = 12; // Zod throws a Type Error Exception const name: fullName = "Nancy"; // Parses safely
Adding a custom validation with Zod
You can create your custom validations with Zod using the refine
and superRefine
APIs described in this post. Combining chainable built-in methods along with refinements
can prove to be very powerful and can be used while defining complex schema objects. In the following example:
const profile = z.object({ firstName: z.string({ required_error: "First name is required", invalid_type_error: "First name must be a string", }), lastName: z.string({ required_error: "Last name is required", invalid_type_error: "Last name must be a string", }), mobileNumber: z.number({ required_error: "Mobile number is required", invalid_type_error: "Mobile number must be a number", }), confirmMobileNumber: z.number({}), }) .refine((data) => data.mobileNumber === data.confirmMobileNumber, { message: "Oops! Phone numbers doesnt match", });
The following object matches the above custom defined validation schema:
const Jane Doe: profile = { firstName: "Jane", lastName: "Doe", mobileNumber: "1111111111", confirmMobileNumber: "1111111111", }
Conclusion
There are more nitty-gritty APIs that Zod provides out of the box, but we have covered almost all the basic entities of building a schema validation with Zod and TypeScript. We also covered how powerful these primitives can be, and, if chained together, can build a very robust and flexible schema design for your application.
Having a runtime validation is always a good idea, as it makes your code more readable and sanitizes user inputs before redirecting them to the server.
Zod emphasizes the DRY principle and makes sure that schema defined in one place can be tokenized and used for other types as well, either by inferring those types or extending them. This makes Zod stand out from other similar libraries.
Pairing Zod with a powerful compile type check system like TypeScript can make applications robust in current Jamstack applications.
LogRocket: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Try it for free.
Correct me if I am wrong, but you don’t use the parse function with TS typically… you infer the type, effectively from the ZOD ‘model’ (I call it) and then you assign that type to the variable, or function return or whatever as you would usually do with TS. Then parse is done inherently as part of type checking, but standardized with ZOD models.
Got a huge app, sharing front and backend code, express/vite. Express has routes hierarchical frontend doesn’t, plus has it’s own router. Trying to share route strings mostly and response types. Also trying to figure out naming conventions… ATM I am using SomethingModel for the ZOD stuff and SomethingModelType for the type inferers but its a bit verbose.