react
zod
typesafety
react-hook-form
trpc

The article is strongly opinionated; it delves into the strengths of Zod and highlights the benefits of using it.

Why You Should Start Using Zod

If you haven't started using Zod yet, it's time to reconsider! This lightweight library significantly simplifies daily tasks, especially those involving validation and defining TypeScript contracts. Zod not only makes things easier but also enhances maintainability and reduces boilerplate. Today, I'll give you a glimpse of the possibilities with Zod that can transform your coding experience.

The Problems Zod Solves

Imagine you have an API that creates a post. Without Zod, here's what the typical code might look like without Zod.

// models.ts file
interface PostData {
  title: string;
  content: string;
  tags?: string[];
}

// validate-post-data.ts file
import { PostData } from "@models";

const validatePostData = (postData: PostData): string | null => {
  if (!postData.title || postData.title.trim() === ``) {
    return `Title is required and cannot be empty.`;
  }
  if (!postData.content || postData.content.trim() === ``) {
    return `Content is required and cannot be empty.`;
  }
  if (postData.tags && postData.tags.some((tag) => tag.trim() === ``)) {
    return `Tags cannot contain empty strings.`;
  }
  return null; // No errors
};

export { validatePostData };

// add-post.ts file
import axios from "axios"; // Ensure axios is installed (`npm install axios`)
import { PostData } from "@models";

const addPost = async (postData: PostData): Promise<void> => {
   if (!validatePostData(postData)) throw Error("Validation error"); 
   await axios.post(`https://example.com/api/posts`, postData);
};

export { addPost };

I've simplified this article by not delving into the presentation layer. What stands out immediately is the complexity of the validatePostData function.

Additionally, any changes to the PostData interface require us to consistently update the validatePostData implementation as well.

What's more, what if one of our validators is flawed? A bug in a validator doesn't just mean maintenance headaches - it means that even if validation passes, we can't be certain the postData truly matches the expected shape - at runtime it may be a null or undefined.

Zod offers a solution to these issues by reducing boilerplate, enhancing readability, and providing guaranteed type safety through defined schemas. Let's improve our code with Zod!

// models.ts file
// Ensure zod is installed (`npm install zod`)
import { z } from 'zod';

const PostSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  content: z.string().min(1, 'Content is required'),
  tags: z.array(z.string()).optional(),
}).strict();

type PostData = z.infer<typeof PostSchema>;

export { PostSchema };
export type { PostData };

// add-post.ts file
// Ensure axios is installed (`npm install axios`)
import axios from 'axios';
import { PostData } from "@models";

const addPost = async (postData: PostData): Promise<void> => {
  // Validates the "postData"
  // with Zod and throw an error if failed.
  PostSchema.parse(postData);
  await axios.post('https://example.com/api/posts', postData);
};

export { addPost };

A crucial feature of Zod is its initial check to ensure that postData is actually an object. Then, it meticulously examines each value within the object. The PostSchema.parse method runs this validation and will throw an error if anything is amiss or invalid.

The strict() method in Zod ensures that an object contains only the specified properties. This is particularly useful for preventing the accidental sending of unnecessary data to the backend. You never know - there could be a bug on the backend side that mistakenly accepts and stores this extraneous data, posing a significant security risk.

Look how the IDE behaves. Instead of just showing an interface name as it would when you define types manually, it displays the exact object structure. This enhances the developer experience because you don’t always have to navigate to the interface file to see its layout.

The Beauty of Zod Nice Developer Experience

To learn more about how Zod can enhance your TypeScript projects, check out this detailed article: Using Zod and TypeScript to Write Type-Safe Code.

Platform Agnosticism of Zod

The best part is that Zod is fully written in TypeScript. This allows you to create a schema library, extract a type from each schema, and then publish the library. As a result, you'll have the same declarations and contracts shared between the backend and frontend. When a schema changes, you can automatically push a new version of the contracts, update them in both projects and safely manage the migration to new contracts on both ends.

// Library code on npm

import { z } from 'zod';

const PostSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  content: z.string().min(1, 'Content is required'),
  tags: z.array(z.string()).optional(),
}).strict();

type PostData = z.infer<typeof PostSchema>;

export { PostSchema };
export type { PostData };

// Frontend app
import { PostData  } from "@4markdown/contracts";

// Backend app
import { PostData  } from "@4markdown/contracts";

Then, in package.json, you can specify a dedicated version of your contracts library:

  "dependencies": {
    "@4markdown/contracts": "2.4.1",

To create such a library, you can use nx to maintain it easily. Here is an article on this topic: Publishing Nx Generated TypeScript Libraries on Npm.

An alternative setup with tRPC is possible, but it's not part of this article.

The tRPC guarantees type safety for each API route and makes maintenance easier. It all depends on the stack you're currently using. Here you can read more about it tRPC.

Real Time Forms Validation

A common scenario involves showing results immediately when a user types or submits a form. With Zod, this becomes extremely easy and simplifies your code significantly. All you need to do is install dedicated adapters - for instance, if you're using React, it would be: npm install react-hook-form zod @hookform/resolvers.

Then, simply define your schemas and enjoy the benefits of type safety.

import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// Define Zod schema
const loginSchema = z.object({
  email: z.string().email("Invalid email format"),
  password: z.string().min(5, "Password must be at least 5 characters")
});

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(loginSchema)
  });

  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" {...register('email')} />
        {errors.email && <p>{errors.email.message}</p>}
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p>{errors.password.message}</p>}
      </div>
      <button type="submit">Login</button>
    </form>
  );
}

export default LoginForm;

Just for your information, here is the size of the gzipped version of each library taken from BundlePhobia during article creation:

  • React Hook Form: 14.5 kB
  • Zod: 9 kB
  • @hookform/resolvers: 1 kB

However, it's a long-term investment. Without these tools, you would need to write your own code to handle such logic, and there's no guarantee that for larger applications, your custom solutions wouldn't eventually surpass the combined size of these three libraries.

The AI support

Zod is well-supported by AIs like ChatGPT. If you ask any question about it, you’ll often receive a ready-to-use schema right out of the box. Frequently, I simply provide the AI with field names, and I get back a solid boilerplate. This serves as a great starting point and significantly boosts productivity. Look how it works:

Zod Schemas Creation with ChatGPT Life Is Insanely Easy

Overview Of Zod Features

There are a ton of possibilities with Zod. Here's a summary:

  • Validate arrays, objects, and any data type.
  • Determine TypeScript type definitions via z.infer.
  • Use unions and enums.
  • Modify the validation parse mechanism to either throw an error or return a flag.
  • Conduct validation in both sync and async manners.
  • Create your own validators.
  • Combine validators via inheritance or direct pick mechanisms to avoid duplication in schemas.
  • Easy to create form validations after installing the adapters mentioned above.

Summary

I remember when Zod first appeared in the community. Initially, I was skeptical, but after incorporating it into several apps, my perspective completely changed. Now, the code I write is simpler, and I avoid the naive approach of blindly trusting the backend.

With Zod, I have schemas that determine what the backend returns, and if it returns an invalid shape, I know immediately. Additionally, I've reduced my workload because I no longer need to duplicate type contracts and their implementations. It automatically gives me a type with z.infer.

I'm not saying it's mandatory, but you should definitely try it and consider using it in your projects if you appreciate making your developer life easier.

Author avatar
About Authorpraca_praca

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.