logo
Basic Utils
Home

Zod Z.Lazy Method

Table of Contents

  1. Introduction to Zod Z.Lazy
  2. Basic Usage of Z.Lazy
  3. Defining Recursive Schemas
  4. Error Handling with Z.Lazy
  5. Practical Examples
  6. Conclusion
  7. Sources

Introduction to Zod Z.Lazy

Overview of z.lazy

Recursive schemas are schemas that refer to themselves. This makes them prone to circular reference problems. For example, Assume you are defining a tree structure. Each item in this type of data structure points to the next item recursively. The z.lazy method can be used in such cases to avoid circular reference issues.

Why Use Z.lazy?

Here is a set of scenarios where the z.lazy method is essential:

  1. Recursive Data Structures. : Trees, linked lists, or Nested comments are all examples of recursive data structures. They are characterized by a schema that must refer to itself. They do this by nodes having child nodes of the same type. This is the self-referential pattern. Each element points to the next, recursively referencing the same structure. For a comment system, a comment can have replies, where each reply is a comment itself.
  2. Complex Validation Logic. : Mostly to do with when that type can be nested or recursive. Situations where the validation rules depend on the type of data being validated.

Without z.lazy defining such schemas would result in a circular dependency. TypeScript and other validation libraries cannot resolve such. This is why we need the z.lazy method.

Basic Usage of Z.Lazy

Basic Syntax and Structure

z.lazy's signature accepts a callback function that returns a Zod schema. This structure allows for deferred evaluations. It means that the schema is evaluated when needed and not immediately.: Here's how to use it:

import { z } from 'zod';
const lazySchema = z.lazy(() => z.string());

lazySchema is defined as a schema that will ultimately validate a string. The use of z.lazy here is trivial but showcases how to set up the basic structure.

Why Use a Callback Function?

The main purpose of the callback is for deferred evaluation of the schema. Typescript would encounter circular references by evaluating it immediately.

A Practical Demonstration

Let's see a more powerful example that shows z.lazy being used to define recursive types.

const numberSchema = z.lazy(() => z.union([z.number(), z.string()]));

console.log(numberSchema.parse(42)); // Valid, outputs: 42
console.log(numberSchema.parse("hello")); // Valid, outputs: "hello"
console.log(numberSchema.parse(true)); // Invalid, throws an error

Above we see that anumberSchema defines a schema that can validate both a number and a string using z.union. This shows the flexible nature of lazy evaluation for handling more dynamic type

Defining Recursive Schemas

Understanding Recursive Schemas

A recursive schema is all about object references itself, directly or indirectly. Its concept is most common in data structures e.g. trees, linked lists, and graphs. Zods z.lazy helps define such schemas while avoiding the circular reference issues.

Creating a Tree Structure Schema

Consider a tree. Each node has a value and may have a set of children where each child in turn is a tree node tool. Here is how z.lazy would be used in such a scenario:

import { z } from 'zod';

const TreeNodeSchema = z.lazy(() =>
z.object({
value: z.number(),
children: z.array(TreeNodeSchema), // Recursively references TreeNodeSchema
})
);

Let's understand what happened above:

  1. We used z.lazy to defer the evaluation of TreeNodeSchema until it’s needed.
  2. TreeNodeSchema is an object schema with the following properties:
  3. value A number representing the value of the node.
  4. children: An array of nodes, each with the same properties as TreeNodeSchema

Example Usage and Validation

Now let's see how we can use this schema to validate a tree-like data structure:

const exampleTree = {
value: 1,
children: [
{ value: 2, children: [] },
{ value: 3, children: [{ value: 4, children: [] }] },
],
};

const parsedTree = TreeNodeSchema.parse(exampleTree);
console.log(parsedTree);

In this example:

  1. The exampleTree object has a root node with a value of 1 and two children nodes.
  2. The second child node has its child, making this a nested structure.
  3. TreeNodeSchema.parse(exampleTree) validates the entire tree structure. It ensures that each node conforms to the defined schema.
{
value: 1,
children: [
{ value: 2, children: [] },
{ value: 3, children: [{ value: 4, children: [] }] }
]
}

Handling Edge Cases in Recursive Schemas

When dealing with recursive schemas you have to watch out for certain edge cases. If you don't you may run into validation errors and performance problems:

  1. Empty Trees: How would you handle the situation where the root has no children?

For this, we modify our code as follows:

const TreeNodeSchema = z.lazy(() =>
z.object({
value: z.number(),
children: z.array(TreeNodeSchema).default([]), // Default to an empty array
})
);
  1. Deeply Nested Trees. : Large or deeply nested trees can result in performance issues during validation.
  2. For this, we would do the following:
const MAX_DEPTH = 10;
const createTreeNodeSchema = (depth = 0): z.ZodType<any> =>
z.lazy(() =>
z.object({
value: z.number(),
children: depth < MAX_DEPTH ? z.array(createTreeNodeSchema(depth + 1)) : z.array(z.never()),
})
);

const LimitedDepthTreeNodeSchema = createTreeNodeSchema();

Such a setup sets a limit on allowed validation depth. This helps avoid infinite loops and StackOverflow issues.

Error Handling with Z.Lazy

Understanding Error Handling in Recursive Schemas

Zods z.lazy integrates well with Zods error reporting. I provide detailed and nested error messages. This is important because handling errors in recursive schemas can get tricky. The error reporting is helpful during the debugging of complex structures. It assists in pinpointing exactly where the issue occurred.

Depth 2: Example of Error Handling with z.lazy

Consider the following invalid tree structure:

const invalidTree = {
value: "not a number", // Invalid value
children: [
{ value: 5, children: [] },
{ value: 15, children: [{ value: 12, children: [] }] },
],
};

const invalidResult = TreeNodeSchema.safeParse(invalidTree);

if (!invalidResult.success) {
console.error("Validation failed:", invalidResult.error.errors);
}

In this case:

  1. The problem is with the data type of the root node. It's a string rather than a number. i.e. it violates the schema definition schema definition (z.number()).
  2. We choose Safeparse rather than Parse to avoid crashing our app. The safeParse method returns a result object. It contains a success boolean and an error object if validation fails.
Validation failed: [
{
message: 'Expected number, received string',
path: ['value'],
code: 'invalid_type',
}
]

This output provides:

  1. Message: The validation error message.
  2. Path: The location of the error in the object (['value']), indicating that the value field is incorrect.
  3. Code: The error code (invalid_type), gives more context on why validation failed.

Nested Error Reporting

Let's see another example. This time we modify the invalid tree to include an error deeper in the hierarchy:

const deeplyInvalidTree = {
value: 1,
children: [
{ value: 5, children: [] },
{
value: 15,
children: [
{ value: "invalid", children: [] }, // Error at a nested level
],
},
],
};

const deepErrorResult = TreeNodeSchema.safeParse(deeplyInvalidTree);

if (!deepErrorResult.success) {
console.error("Validation failed:", deepErrorResult.error.errors);
}

Output:

Validation failed: [
{
message: 'Expected number, received string',
path: ['children', 1, 'children', 0, 'value'],
code: 'invalid_type',
}
]

In this example:

  1. The error message provided shows the path to the invalid value (['children', 1, 'children', 0, 'value']
  2. This helps in pinpointing where the issue lies in the nested structure.

Custom Error Messages for Recursive Schemas

Zod also allows us to customize error messages. This can be useful to provide more meaningful feedback. We take advantage of the z.refine() method for this:

const CustomTreeNodeSchema = z.lazy(() =>
z.object({
value: z.number().or(z.nan()).refine((val) => val >= 0, {
message: "Node value must be a non-negative number",
}),
children: z
.array(TreeNodeSchema)
.refine((children) => children.length <= 10, {
message: "A node cannot have more than 10 children",
}),
})
);

const customErrorTree = {
value: -1, // Invalid: negative number
children: Array(12).fill({ value: 2, children: [] }), // Invalid: more than 10 children
};

const customErrorResult = CustomTreeNodeSchema.safeParse(customErrorTree);

if (!customErrorResult.success) {
console.error("Validation failed:", customErrorResult.error.errors);
}

Output

Validation failed: [
{
message: 'Node value must be a non-negative number',
path: ['value'],
code: 'custom_error',
},
{
message: 'A node cannot have more than 10 children',
path: ['children'],
code: 'custom_error',
}
]

In this customized example:

  1. The validation for value ensures it is a non-negative number.
  2. The children's array is limited to a maximum of 10 elements. It enhances the constraints on the tree structure.
  3. The error messages are customized to provide clear and specific feedback.

Practical Examples

Example 1: Validating a Hierarchical Structure

Consider a company structure where each employee can have subordinates. We can model this using z.lazy:

const EmployeeSchema = z.lazy(() =>
z.object({
name: z.string(),
position: z.string(),
subordinates: z.array(EmployeeSchema),
})
);

const companyStructure = {
name: "Alice",
position: "CEO",
subordinates: [
{
name: "Bob",
position: "CTO",
subordinates: [],
},
{
name: "Charlie",
position: "CFO",
subordinates: [
{
name: "David",
position: "Accountant",
subordinates: [],
},
],
},
],
};

const companyResult = EmployeeSchema.safeParse(companyStructure);

if (companyResult.success) {
console.log("Company structure is valid:", companyResult.data);
} else {
console.error("Company structure validation failed:", companyResult.error.errors);
}

Example 2: Complex JSON Validation

You may receive complex JSON data from an API, and you need to ensure it adheres to a specific schema. Here’s how you can leverage the z.lazy method:

const ComplexSchema = z.lazy(() =>
z.object({
id: z.string(),
attributes: z.object({
name: z.string(),
age: z.number(),
children: z.array(ComplexSchema),
}),
})
);

const jsonData = {
id: "123",
attributes: {
name: "John Doe",
age: 30,
children: [
{
id: "124",
attributes: {
name: "Jane Doe",
age: 10,
children: [],
},
},
],
},
};

const jsonResult = ComplexSchema.safeParse(jsonData);

if (jsonResult.success) {
console.log("JSON data is valid:", jsonResult.data);
} else {
console.error("JSON validation failed:", jsonResult.error.errors);
}

Conclusion

The z.lazy method in Zod is a powerful feature that enables users to define recursive schemas easily. It simplifies the process of validating complex data structures. z.lazy method ensures robust data validation in applications.

Sources

  1. Zod Documentation
  2. TypeScript Official Documentation
  3. Zod GitHub Repository
logo
Basic Utils

simplify and inspire technology

©2024, basicutils.com