With the rapid development of modern applications, data integrity is paramount. As often said, a developer should never trust user inputs. Therefore, user data has to be in a conformed structure or schema.
This can be achieved using a powerful schema library called Zod. When TypeScript is integrated with Zod, type safety can be extended to the data layer. Also, Zod extends the compile-time type checking of TypeScript to runtime, which ensures that unexpected data is blocked.
This blog post will introduce schema validation, explain what Zod is and how to set it up, discuss some of its basic features, and delve into other complex features.
Introduction to Schema Validation
This section discusses the concept of schema validation and the fundamental requirements to maintain uniformity in data structures.
Schema validation is the process of ensuring that incoming user data conforms with certain specifications. Imagine security personnel at a nightclub checking to see if everyone is wearing the appropriate dress code. This is what schema validation does. The dress code is the schema in this example.
A schema is a predefined set of rules that sets a structure, format, and constraints for the data. It typically states how data should look in an application.
Depending on the project, the criteria for schema validation can vary. However, the following are the fundamental requirements to ensure uniformity in data:
Data types: It ensures that the data conforms to a specific data type.
Field constraint: It sets constraints like the maximum or minimum length of a particular field.
Required Fields: It specifies which field is mandatory or not.
Introducing Zod
This section explains what Zod is, its key features, and how to install and get started with it.
Zod is a schema declaration and validation library used in mostly TypeScript projects. It defines and validates data structures and ensures easy integration into Typescript projects.
It offers simple and expressive syntax that can deliver concise and well-structured schema. It allows the definition of data types and constraints, providing a good understanding of expected data in the codebase.
Zod offers runtime validation. It means it continues to validate the data even after TypeScript code has been translated into JavaScript, adding extra security to the application development process.
Here are some key features of Zod:
Schema definition: Zod’s simple syntax makes it easy for developers to define data structures, types, and set constraints.
Seamless integration: Zod integration with TypeScript is an easy one. However, it can also be used in JavaScript.
Built-in validators: Zod offers built-in validators for validating emails, strings, etc.
Custom validation: Custom validation logic implementation is straightforward in Zod. It offers flexibility to validate data based on project-specific requirements.
Composability: Zod schemas can be reused and composed into a more complex data structure.
Installation and Setup
There are a few requirements before using Zod in this guide. There are:
Vite project. Execute
npm create vite@latest
on the terminal. Note that you don’t need to know anything about Vite. It will enable us to work in a TypeScript environment.
To install Zod, run npm install zod
on the terminal.
Tutorial file structure
The main code will be implemented in the main.ts
file. To run this file, execute npm run dev
on the terminal.
Basic Schema Validation
This section explores validations for data types, both primitives and objects.
Zod Data Types
The following are some of the Zod data types to be focused on:
z.string()
z.number()
z.boolean()
z.date()
z.enum()
z.object()
These data types can be used to build schema validation.
import { z } from "zod";
const UserSchema = z.object({
username: z.string(),
});
const user = { username: "John" };
const result = UserSchema.safeParse(user);
console.log(result);
Firstly, the z
object is imported, and it enables the creation of a data schema.
Secondly, theUserSchema
is defined with the z.object
method that specifies an object expecting a username
property whose value should be a string.
Lastly, an user
object is defined with the property username
. The safeParse
method includes the parsed object and a message indicating whether the validation was successful or not.
Here is the result on the browser console.
Suppose a number is passed as the value to the property username; an error will occur.
Here is the result if an error occurs.
More properties can be added to the UserSchema
object along with validations.
const UserSchema = z.object({
fullName: z.string().trim(),
username: z.string().min(5).max(9),
email: z.string().email(),
address: z.string().min(5).max(50),
phoneNumber: z.string().length(13).startsWith("+234").trim(),
height: z.number().positive().gt(170),
yearsOfExperience: z.number().gte(2),
numberOfDependants: z.number().lte(3),
age: z.number().gte(18).int(),
dateOfBirth: z.date().max(new Date("2006–01–01")).min(new Date("1900–01–01")),
isMarried: z.boolean(),
isProgrammer: z.boolean().default(true),
website: z.string().url(),
hobby: z.enum(["Programming", "Weight Lifting", "Music"]),
});
It is important to note that, by default, Zod expects every property to be defined. To avoid this, specify the optional()
validation.
min()
: Specifies the minimum number of characters.max()
: Specifies the maximum number of characters.length()
: Specifies the exact number of characters.email()
: Specifies a property that must adhere to the standard email format.url()
: Specifies a property that must adhere to a standard URL address.trim()
: Remove whitespace.gte()
: Specifies that a property value must be greater than or equal to a specified number.lte()
: Specifies that a property value must be less than or equal to a specified number.gt()
: Specifies that a property value must be greater than a specified number.startsWith()
: Specifies that a string must start with a particular sub-string.positive()
: Specifies that a number must be positive.int()
: Specifies that a number must be an integer.max(new Date())
: Specifies the maximum date of a property.min(new Date())
min(new Date())`: Specifies the minimum date of a property.enum()
: Specifies a list of values that can be accepted.
The following is an example of an object that fits the above schema.
const user = {
username: "john_doe",
fullName: "John Doe",
phoneNumber: "+234812345678",
email: "john.doe@example.com",
address: "123 Main Street, Lagos",
height: 175,
yearsOfExperience: 7,
numberOfDependants: 2,
age: 39,
dateOfBirth: new Date("1985–01–01"),
isMarried: true,
isProgrammer: true,
website: "https://www.johndoe.com",
hobby: "Music",
}
Here is the result.
Error handling and Error Customization
Every error in Zod is an instance of the ZodError
class. This class is a subclass of the JavaScript Error
class.
class ZodError extends Error {
issues: ZodIssue[];
}
The issues property holds an array named ZodIssue
. This array holds the problems that may have occurred during validation. The following fields exist in every ZodIssue
.
code: This identifies the type of error that occurred.
path: This indicates the location of the error.
message: This provides a detailed explanation of the error.
Error handling is achieved by executing the JavaScript try
and catch
technique.
import {z, ZodError} from "zod"
const UserSchema = z.object({
username: z.string(),
age: z.number()
})
const user = {username:'John', age:'24'}
try {
console.log(
UserSchema.parse(user));
}
catch (error) {
if (error instanceof ZodError) {
console.log(error.issues)
}
}
The age
property in the user
object is passed as a string instead of a number. Zod will throw a ZodError
and the catch
block will get the error information.
Errors can be customized with the z.ZodErrorMap
error mapping function. With this function, a specific error message can be defined for different validation issues that may occur.
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "bad type!" }
}
}
return { message: ctx.defaultError }
}
z.setErrorMap(customErrorMap)
The ZodErrorMap
function takes two arguments; issue
and ctx
. issue
represents a specific validation issue whilectx
gives extra insights on error mapping.
z.string().parse(23, { errorMap: customErrorMap })
The code above returns a ZodError
which contains the customized error message, bad type
since it’s expecting a string.
Zod Refinements
Certain validation types aren’t available in the TypeScript type system. Zod implements these TypeScript validation features.
As such, Zod refinement feature offers two APIs that implement additional custom validation functions. The refine()
method creates refinements. It takes two arguments:
A validation function.
An optional object with options that specify custom error-handling behavior.
The refine()
method to customize the validation of a user’s age
.
const UserSchema = z.object({
age: z.number().refine((age) => age >= 18, {
message: 'Age must be at least 18'
})
})
In this example, refine()
is used to validate the age
property. The function passed into the method checks if age
is greater than or equal to 18. If it is less than 18, it returns a false value.
The refine()
method can be asynchronous. In this case, data must be parsed and validated using the parseAsync()
method else an error will occur.
const UserSchema = z.object({
username: z.string().refine(async (username) => username.length <= 9 && username.length >= 5, {
message: 'username must be at most 9 characters and at least 5 characters'
})
})
const user = { username: "JohnDoe" }
await UserSchema.parseAsync(user)
In the example above, the data is validated asynchronously against the schema.
The second refinement method is superRefine()
. It is more versatile than the refine()
method. It accepts a function with two arguments, namely:
data
: Parsed data being validated.ctx
: A context object with a method calledaddIssue
.
const UserSchema = z.string().superRefine((val, ctx) => {
if (val.length>=9) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 9,
type: 'string',
inclusive: true,
message: "Username should be at least 5 characters and at most 9 characters",
})
}
})
The superRefine()
method validates the length of a string. If the length of the string is greater than 9, it returns an error code with a very concise customized error message.
Zod Custom Validation
Zod provides two approaches to custom validation. These approaches involve using the refine()
and superRefine()
methods. It offers more flexibility, precise error reporting, and modularization.
import { z } from "zod"
const UserSchema = z
.object({
username: z.string({
required_error: "Username is required",
invalid_type_error: "Username must be a string",
}),
email: z
.string({
required_error: "Email is required",
invalid_type_error: "Email must be a string with valid email format",
})
.email(),
password: z
.string({
required_error: "Email is required",
invalid_type_error: "Password must be more than 5 characters",
})
.min(5),
confirmPassword: z.string().min(4),
})
.superRefine(({ confirmPassword, password }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
})
}
})
In the above example, custom error message validation was created for every property using the required_error
and the invalid_type_error
options. The superRefine()
method adds custom validation logic to the password
and confirmPassword
fields. A custom error message occurs If these fields don’t match
const user = {
username: 'johndoe',
email: 'johndoe@gmail.com',
password: 'johndoe',
confirmPassword:'johndoe'
}
The above object matches the custom validation rules defined in the UserSchema.
Type Inference
Zod integrates well with TypeScript in that it leverages TypeScript’s type inference. It uses static type to check the data against the schema.
import { z } from "zod"
const UserSchema = z.object({
username: z.string(),
email: z.string(),
phoneNumber: z.string().optional()
})
type User = z.infer<typeof UserSchema>
The UserSchema
is defined and z.infer
infers the TypeScript type User
based on the UserSchema
. The type of the User
will be derived from the UserSchema
object.
const user: User = {
username: 'johndoe',
email: 'johndoe@gmail.com',
phoneNumber:'08123456789'
}
The user
object is defined as an instance of the User
type. The user
object must adhere to the structure of the User
type.
Conclusion
Schema validation is a necessary functionality in application development as it ensures data integrity. Zod is one tool used to achieve this. It offers many features, and in this article, we have covered enough information to get started in schema validation.
We started by learning about some data types available in Zod and how to use them to build a robust data schema. It can validate user input at runtime and how it can integrate with TypeScript. We further explored more complex features like error handling and custom validations.
Zod flexibility and reliability have made it a necessary tool as it helps developers reduce errors, build high-performance applications, and provide data integrity.