Blog

Organizing Zod Schemas for CRUD Applications

December 26, 2023

635 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

FieldDescription
idThe unique identifier of the todo list
titleThe name of the Todo list
userIdThe owner of the Todo list

Todo Table

FieldDescription
idThe unique identifier of the todo
titleThe title of the todo item e.g "Get some food"
doneWhether or not the todo is completed
todoListIdThe 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 resource
  • Create schema contains all of the base properties plus any additional required properties for creating the resource such as foreign keys
  • Resource schema contains all the properties used to create the resource, plus any additional properties the exist after creation such as id, createdAt, and updatedAt.
  • Find schema contains the properties that can be used to uniquely identify the resource
  • Update 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's id is not yet known. The backend API will take care of creating the TodoList first, and then its Todo'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 Todos to be upserted, or of existing Todos 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:

  1. infer or output for extracting the final type of the validator - what comes out on the other side of the validator
  2. input 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 and db.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.

r.e

© 2021-2024 Rexford Essilfie. All rights reserved.