Abhinav Anshul Doing interesting things on the Web.

Schema validation in TypeScript with Zod

5 min read 1603

Schema Validation In TypeScript With Zod

TypeScript is awesome! It has improved developer productivity and tooling over recent years. TypeScript not only helps with static type checking, but also has added a set of object-oriented programming (OOP) concepts such as generics, modules, classes, interfaces, and more.

Arguably, going back to a JavaScript-only codebase can be difficult if you have worked with TypeScript. Although TypeScript looks great in all aspects, it has a blind spot — it only does static type checking at compile time and doesn’t have any runtime checks at all.

That’s where Zod comes in. In this article, you will learn about schema design and validation in Zod and how to run it in a TypeScript codebase at runtime.

What is Zod and why do we need it?

You might be asking yourself, why would someone need a runtime check in the first place?

Well, runtime checks help in getting correctly validated data on the server side. In a case where the user is filling out some kind of form, TypeScript doesn’t know if the user inputs are as good as you expect them to be on the server at runtime.

Therefore, Zod helps with data integrity and prevents sending out garbage values to the database. Also, it’s better to log an error on the UI itself, like in cases where a user typed in numbers when you expect a string.

Zod is a tool that solves this exact problem. It fills this TypeScript blindspot and helps with type safety during runtime. Zod can help you build a pretty flexible schema design and run it against a form or user input.

Why use Zod over other tools?

There are already a couple of tools such as Yup, Joi, io-ts, and more that do the same thing as Zod. While all these libraries are great for schema designing and doing runtime validation in general, Zod outshines itself in several factors such as:

  • Flexibility: Zod is very flexible and can chain several instances together, thereby preventing duplication of static types
  • It has no dependencies
  • Although it is best to use Zod along with TypeScript, if you want a limited, Zod-only type safety, you can use it in plain JavaScript projects as well. It is not TypeScript dependent
  • It focuses on immutability, therefore some methods like optional() return a new instance instead of mutating the same object altogether

Benefits of using Zod with TypeScript

Zod works particularly well with TypeScript. In a typical TypeScript codebase, you ensure that all the static type safety will be handled by TypeScript at compile time. Even most of the third-party packages ship their code along with their types, like how React has its types under an npm package called @types/react.

Having an additional runtime check using Zod fixes these issues with TypeScript. You might have encountered issues where you need to assign undefined or unknown to a type even after using its corresponding @types package — the sole reason being you don’t know beforehand what a user would enter or what the response structure is like.

Zod applies these runtime checks in a very concise way. That is literally like taking data types from TypeScript and extending them to Zod-specific methods. Zod automatically infers the type that you have mentioned already in TypeScript, preventing type duplication.

These kinds of chainable Zod’s utility methods have inspired TypeScript utilities as well, proving how well these two go along together.

Additionally, there are situations where TypeScript’s error handling would be suboptimal and Zod could do a better job displaying the error at runtime, based on a user’s interaction with the UI.



Primitives in Zod

Let’s start talking about schema validation with a very basic Zod example:

import { z } from "zod"
const dataInputFromUser = z.string().min(8).max(16)
dataInputFromUser.parse("A long text")

The above code will safely parse. You can navigate the user to the next input or a page, depending on your use case.

And if we tweak the line a bit, like this:

dataInputFromUser.parse("A really long text")

It’ll throw an exception:

errors: [
    {
      code: 'too_big',
      maximum: 16,
      type: 'string',
      inclusive: true,
      message: 'String must contain at most 16 character(s)',
      path: []
    }
  ]

If you need safer exception handling, you can simply log the error using the .safeParse() method.

This is one of the simplest examples of using primitives in Zod. Primitive values are not just limited to string, but provide other methods such as number, bigint, boolean, and date. There are a couple of empty types as well like undefined, null, and void.

Utilizing these primitives along with a few specific methods can lead to a very flexible schema design:

// email validation //
z.string().email().startsWith(string).trim().max(18).min(1)
// can be used for Terms & Conditions check at runtime //
z.boolean()

// can be used for high-precision large values if they are being calculated at runtime //
z.bigint()

The example above is chaining these primitives together to create a very functional type safety at runtime for an email input field.

Objects in Zod

Most of the user-facing form requires a couple of data inputs and validation of varying data types. This is where it’s better to use objects in Zod. You can create a schema for a set of properties you want at a runtime check.

import { z } from 'zod'

const FormData = z.object({
  firstName: z.string().min(1).max(18),
  lastName: z.string().min(1).max(18),
  phone: z.string().min(10).max(14).optional(),
  email: z.string().email(),
  url: z.string().url().optional(),
});

const validateFormData = (inputs: unknown) => {
  const isValidData = FormData.parse(inputs);
  return isValidData;
};

In the above TypeScript code, there is no way to enforce schema validation at runtime using TypeScript only, hence the inputs: unknown.

This is where z.Object() can be used for building an extensible schema validation. The data will be safely parsed if a user enters the fields that satisfy your schema definition. You’ll then be able to send that data to the server. Else, an exception will be thrown, as we saw in the beginning.

Composing complex schema objects

There could be such cases where the schema object design is nearly the same for a couple of forms/fields, apart from a few extra or missing types. In those situations, you don’t need to keep duplicating the same object schema over and over again. Instead, you can prevent duplication by utilizing Zod methods such as merge() and extend().


More great articles from LogRocket:


The code above can be further improved by removing the duplication for firstName and lastName types, like so:

const GenericStringContraint = z.string().min(1).max(18),

const FormData = z.object({
  firstName: GenericStringContraint, 
  lastName: GenericStringContraint, 
  // ...
});

This is similar to a chunk of data types being repeated in a couple of forms. Say, for example, userId and fullName get repeated in Article and UserProfie schema definitions, then we can simply “extend” these two as:

const UserData = z.object({
  userId: z.string().min(1).max(5),
  fullName : z.string().min(1).max(18),
});

const Article = UserData.extend({
  title: z.string().min(5),
 date: z.date()
});

const UserProfile = UserData.extend({
  isVerifield: z.boolean(),
  numberOfArticles: z.number().positive()
});

The above approach can be a better way to have maintainable code and avoid data duplication. One thing to be mindful of while using extend() is that it mutates the schema objects, i.e., overwrites them.

Very similar to the extend() method, Zod provides a merge() method as well, which differs slightly. It is useful while merging two schema objects, not necessarily “extending” them:

import { z } from "zod"

const User = z.object({
  url: z.string().email().min(8),
  name: z.string(),
  age: z.number().min(2).max(3).optional()
})

const Skill = z.object({
  title: z.string().min(1),
})
const SkilledUser = User.merge(Skill)

const Data = SkilledUser.parse({
  url: "[email protected]",
  name: "Nancy",
  age: 21,
  title: 'water surfing',
})

console.log(Data) // parses successfully

As you can see in the code above, the title data field has been merged to the User schema object, hence why it’d be parsed safely by Zod. In a case where the title field is a number, say 20, Zod would throw an error mentioning that title is constrained to the type of z.string().min(1) only.

Zod’s merge() property doesn’t rewrite the fields as it is in the case with extends().

Type inferences in Zod

Suppose you already have your type defined somewhere and you want a newly created variable to deduce its type from existing ones. In that case, Zod has a method that can infer its type, as:

let fullName = z.string(); 
type fullName = z.infer<typeof fullName> // string

const userAge: fullName = 12; // Zod throws a Type Error Exception
const name: fullName = "Nancy"; // Parses safely

Conclusion

There are more nitty-gritty APIs that Zod provides out of the box, but we have covered almost all the basic entities of building a schema validation with Zod and TypeScript. We also covered how powerful these primitives can be, and, if chained together, can build a very robust and flexible schema design for your application.

Having a runtime validation is always a good idea, as it makes your code more readable and sanitizes user inputs before redirecting them to the server.

Zod emphasizes the DRY principle and makes sure that schema defined in one place can be tokenized and used for other types as well, either by inferring those types or extending them. This makes Zod stand out from other similar libraries out there.

Pairing Zod with a powerful compile type check system as TypeScript can make applications robust in current JAMstack applications.

: Full visibility into your web and mobile apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

.
Abhinav Anshul Doing interesting things on the Web.

Leave a Reply