Blog
Organizing Zod Schemas for CRUD Applications
December 26, 2023
639 Views
The Zod validation library is big gift to the JavaScript world. Since discovering it, I use it in almost all my projects. However, it was a little hard to structure for growing projects.
After sometime using and refining my approach to validation, I would like to share a system that has helped me move fast and easy when building small to medium-sized CRUD applications.
What is Zod?
At its core, Zod is a data validation library. It allows you to define validators that take in data and check whether the data matches the schema of your validator.
Beyond validation, Zod (and its eco-system) allow you to do a lot more. It can also be used for:
- Type extraction/inference from validators,
- Apply transformations on data,
- Use validators as a driver for rendering forms, and more.
Together, these purposes unite to help developers enforce API contracts across data "providers" and "consumers" - between client and server.
Zod ensures that your server "recognizes" the data it receives from clients, and that clients understand the "shape" of data it receives from the server (when types are shared between/inferred from the server).
CRUD Applications
When building a CRUD (Create, Read, Update, Delete) application, a straightforward way to organize the "data" in your application is by "resource". Think of resources such as user
, todo
, todoList
, comment
, etc.
A resource has its own set of logically related properties and often constraints between properties. Resources also often have their own database tables, and CRUD operations associated with them.
Naturally, since we are organize data by resources, it makes sense to organize validators in the same way as well!
Resource-Oriented Validators
Resource-oriented validators are designed to match resources as they might be stored in a database table. In this approach validators are organized around CRUD operations.
As a case study, we will deal with the following resources for a simple todo-list application:
TodoList
Table
Field | Description |
---|---|
id | The unique identifier of the todo list |
title | The name of the Todo list |
userId | The owner of the Todo list |
Todo
Table
Field | Description |
---|---|
id | The unique identifier of the todo |
title | The title of the todo item e.g "Get some food" |
done | Whether or not the todo is completed |
todoListId | The id of the todo list this todo belongs to |
With these two resources, let us explore the principles for organizing our validator schemas:
Base
schema contains intrinsic properties of the resourceCreate
schema contains all of the base properties plus any additional required properties for creating the resource such as foreign keysResource
schema contains all the properties used to create the resource, plus any additional properties the exist after creation such asid
,createdAt
, andupdatedAt
.Find
schema contains the properties that can be used to uniquely identify the resourceUpdate
schema contains properties that allow you to uniquely find AND modify the resource
Here is what this looks like in practice:
todo.validator.ts (Base)
TS
export const todoBaseSchema = z.object({
title: z.string().optional(),
done: z.boolean().optional().default(false),
});
export const todoBaseSchema = z.object({
title: z.string().optional(),
done: z.boolean().optional().default(false),
});
The 'Base' schema
todo.validator.ts (Create)
TS
export const todoCreateSchema = todoBaseSchema.extend({
todoListId: z.string(),
});
export const todoCreateSchema = todoBaseSchema.extend({
todoListId: z.string(),
});
The 'Create' schema
todo.validator.ts (Resource)
TS
export const todoSchema = todoCreateSchema.extend({
id: z.string(),
createdAt: z.datetime(),
updatedAt: z.datetime(),
});
export const todoSchema = todoCreateSchema.extend({
id: z.string(),
createdAt: z.datetime(),
updatedAt: z.datetime(),
});
The 'Resource' schema
todo.validator.ts (Find)
TS
export const todoFindSchema = todoSchema.pick({ id: true });
export const todoFindSchema = todoSchema.pick({ id: true });
The 'Find' schema
todo.validator.ts (Update)
TS
export const todoUpdateSchema = z.object({
filter: todoFindSchema,
update: todoBaseSchema.partial(),
});
export const todoUpdateSchema = z.object({
filter: todoFindSchema,
update: todoBaseSchema.partial(),
});
The 'Update' schema
And that's it! We have now organized our validators for the Todo
resource. We can now do similar for the TodoList
resource, but with one extra consideration.
You may want to have an API that receives both properties for the TodoList
and its Todo
's at the same time. This is where nested resources come in.
Nested Resources
The power of the resource oriented approach shines when you have nested resources. In our case, a TodoList
can have many Todo
's for it, and our backend API should be able to
accept and validate requests for creating/updating a TodoList
and its Todo
's at the same time.
The principles we discussed before are still the same, we just need to identify where the best place is to put the nested resources. For completeness, we will spell everything out.
todolist.validator.ts (Base)
TS
export const todoListBaseSchema = z.object({
title: z.string().optional(),
});
export const todoListBaseSchema = z.object({
title: z.string().optional(),
});
The 'Base' schema
todolist.validator.ts (Create)
TS
export const todoListCreateSchema = todoListBaseSchema.extend({
userId: z.string(),
todos: z.array(todoBaseSchema),
});
export const todoListCreateSchema = todoListBaseSchema.extend({
userId: z.string(),
todos: z.array(todoBaseSchema),
});
The 'Create' schema
💡 For Create, we use the nested resource's Base because the
TodoList
is not created yet and so it'sid
is not yet known. The backend API will take care of creating theTodoList
first, and then itsTodo
's
todolist.validator.ts (Resource)
TS
export const todoListSchema = todoListCreateSchema.extend({
id: z.string(),
createdAt: z.datetime(),
updatedAt: z.datetime(),
});
export const todoListSchema = todoListCreateSchema.extend({
id: z.string(),
createdAt: z.datetime(),
updatedAt: z.datetime(),
});
The 'Resource' schema
💡 For Resource, no modifications needed
todolist.validator.ts (Find)
TS
export const todoListFindSchema = todoListSchema.pick({ id: true });
export const todoListFindSchema = todoListSchema.pick({ id: true });
The 'Find' schema
💡 For Find, there are no modifications needed.
todolist.validator.ts (Update)
TS
const newOrExistingTodo = todoBaseSchema.or(
todoSchema.pick({ id: true }).merge(todoBaseSchema.partial())
);
export const todoListUpdateSchema = todoListBaseSchema.extend({
filter: todoListFindSchema,
update: todoListBaseSchema.partial().extend({
todos: z.array(newOrExistingTodo).optional(),
}),
});
const newOrExistingTodo = todoBaseSchema.or(
todoSchema.pick({ id: true }).merge(todoBaseSchema.partial())
);
export const todoListUpdateSchema = todoListBaseSchema.extend({
filter: todoListFindSchema,
update: todoListBaseSchema.partial().extend({
todos: z.array(newOrExistingTodo).optional(),
}),
});
The 'Update' schema
💡 For Update, we allow validation of either new
Todo
s to be upserted, or of existingTodo
s to be updated.
This completes our discussion of nested resources. The benefits to this organization can be useful to ensure that your API's remain consistent and extensible. A change in validation or transformation for a nested resource is instantly reflected everywhere it is used.
Type Extraction
As mentioned previously, Zod gives us an easy way to extract types from our validators. Zod provides three different helpers for extracting types: infer
, input
, output
.
The best one to use will depends on the context in which the types will be used. Here are the guiding principles for the types:
infer
oroutput
for extracting the final type of the validator - what comes out on the other side of the validatorinput
for extracting the type that is accepted by the validator
The concept of input and output types come into play when validators also perform the role of Data Transformation.
To illustrate the point consider the following more complex schema for the done
field of a Todo
. This one accepts booleans, and 'yes'/'no', 'on'/'off' strings and 0 or 1:
TS
const doneSchema = z.union([
z.boolean(),
z
.enum(["on", "off", "yes", "no"])
.transform((val) => val === "on" || val === "yes"),
z.union([z.literal(0), z.literal(1)]).transform((val) => !!val),
]);
const doneSchema = z.union([
z.boolean(),
z
.enum(["on", "off", "yes", "no"])
.transform((val) => val === "on" || val === "yes"),
z.union([z.literal(0), z.literal(1)]).transform((val) => !!val),
]);
TS
type DoneInput = z.input<typeof doneSchema>; // boolean | 0 | "on" | "off" | "yes" | "no" | 1
type DoneOutput = z.output<typeof doneSchema>; // boolean
type Done = z.infer<typeof doneSchema>; // boolean
type DoneInput = z.input<typeof doneSchema>; // boolean | 0 | "on" | "off" | "yes" | "no" | 1
type DoneOutput = z.output<typeof doneSchema>; // boolean
type Done = z.infer<typeof doneSchema>; // boolean
What does this difference make practically for our CRUD applications?
Well it means that functions that expect to receive un-parsed or un-validated data (e.g straight from the client)
can define their argument types using z.input
, while functions that expect to receive (or return) parsed or validated data can define their argument (or return type) using z.infer
.
todo.types.ts
TS
export type ITodo = z.infer<typeof todoSchema>;
// These are typically used in contexts where we are receiving un-parsed data
export type ITodoBase = z.input<typeof todoBaseSchema>;
export type ITodoCreate = z.input<typeof todoCreateSchema>;
export type ITodoUpdate = z.input<typeof todoUpdateSchema>;
export type ITodoFind = z.input<typeof todoFindSchema>;
export type ITodo = z.infer<typeof todoSchema>;
// These are typically used in contexts where we are receiving un-parsed data
export type ITodoBase = z.input<typeof todoBaseSchema>;
export type ITodoCreate = z.input<typeof todoCreateSchema>;
export type ITodoUpdate = z.input<typeof todoUpdateSchema>;
export type ITodoFind = z.input<typeof todoFindSchema>;
CRUD Operations
So far we have seen how to organize validators and extract types from them. All of this has been leading up to this final point, how we use the validators and types in our CRUD handlers. I will showcase an example of the nested case to highlight its usefulness:
todoList.crud.ts (Update)
TS
export async function updateTodoList(params: unknown) {
const { todoList, update } = todoListCreateSchema.parse(params);
const { todos: todosData, ...todoListData } = update;
const todoList = await db.updateTodoList(todoListData, todoList.id);
const todos = await db.bulkUpsertTodos(todosData, todoList.id);
return { ...todoList, todos };
}
export async function updateTodoList(params: unknown) {
const { todoList, update } = todoListCreateSchema.parse(params);
const { todos: todosData, ...todoListData } = update;
const todoList = await db.updateTodoList(todoListData, todoList.id);
const todos = await db.bulkUpsertTodos(todosData, todoList.id);
return { ...todoList, todos };
}
The implementation details of
db.insertTodoList
anddb.bulkUpsertTodos
are being intentionally left out as an exercise to the reader.
todoList.crud.ts (Read)
TS
export async function readTodoList(params: unknown) {
const data = todoListFindSchema.parse(params);
const todoList = await db.getTodoList(data.id);
return todoList;
}
export async function readTodoList(params: unknown) {
const data = todoListFindSchema.parse(params);
const todoList = await db.getTodoList(data.id);
return todoList;
}
Again, I will leave the implementation of Create and Delete in CRUD as an exercise to the reader!
Extra: Databases and Forms
This resource-oriented validator concept can be taken further.
We can use the types from our validators to inform your database schema or vice-versa, let the database inform your resource validators).
For example, Drizzle ORM's, in-built drizzle-zod
package which automatically generates insert/select Zod validators from the database schema.
Furthermore, there are some extra considerations and tweaks I make my validators "form-aware", or useful for parsing FormData
objects as well. Practically, this is handled by a library such as zod-form-data
, or with our own custom transforms.
Observations
So far, from using this approach here are some things I am noticing:
Observation 1. the resource-oriented approach provides a straight-forward way to extending and modifying resources. Any change starts with a change to a validator, and then the effects of that change ripple out all the way through the entire code-base.
Observation 2. The decision making time when introducing new resources is reduced in most cases for me. Adding a new resource looks like this: 1. identify the properties for the resource, 2. decide which properties belong in the validator kind. After this, the CRUD operation handlers almost write themselves.
Observation 3. Having modular pieces as Base
validators/types also allows for sharing and combination of resource properties in different ways to suite needs of your application. Need an API that accepts both a User
and Todo
in one go, simply combine their Base
kinds into a one-off validator and it is ready for use.
Observation 4. I have often gone on to add more kinds of validators such as Filter
, BulkCreate
, BulkRemove
etc. for extended functionality and easier use from the client. Here is an example of a BulkCreate
schema which hoists shared details into an info
property.
TS
const bulkCreateTodosSchema = z.object({
info: createSchema.pick({ todoListId: true }),
todos: z.array(createSchema.omit({ todolistId: true })),
});
const bulkCreateTodosSchema = z.object({
info: createSchema.pick({ todoListId: true }),
todos: z.array(createSchema.omit({ todolistId: true })),
});
Disclaimer
I typically use this approach to organizing schemas primarily in a Backend for Frontend (BFF) application environment such as Next.js, where the backend exists to serve the needs of one kind of client (a website) and that lives in the same codebase as the server.
Such an environment grants a LOT of flexibility in making changes to API surface, compared to other environments such as mobile for example. Furthermore, using this in Typescript, or in a Typescript enabled editor ensures that type changes are surfaced early.
In other application environments where you want very controlled updates to resource/API interfaces (e.g SaaS Backends, Backend for Mobile Frontend), the Resource-oriented validator organization may not work as well.
Conclusion
That's it! I hope this was helpful, and I'd love to continue the discussion over on X or elsewhere. 'Till the next one. Stay valid.