Zod: Schema Declaration and Validation in TypeScript

Zod is a TypeScript-first schema declaration and validation library. Zod tries to address TypeScript blindspot. TypeScript only does static type checking at compile time and doesn’t have any runtime checks at all. Zod lets us work around this by checking types at the runtime level as well as the type level. With Zod, you can validate at both runtime and compile time.

Why Zod?

Zod has several advantages over other schema validation libraries such as Yup, Joi, and io-ts. For example, Zod is very flexible and can chain several instances together, thereby preventing duplication of static types. It has no dependencies which means that it can be installed and used without any other libraries. This helps keep bundle size smaller and makes it a good choice for projects that need to support a wide range of platforms. Additionally, Zod works well with TypeScript since it is designed to be TypeScript-first. The library will automatically infer the static TypeScript type for data structures so there's no need to declare types twice - once in Zod and again in TypeScript.

Zod can be used for various purposes such as validating HTTP response bodies or validating JSON Schema against actual data. Zod is also better for validating data passed between client and server.

Primitives in Zod

In Zod, primitives are the basic data types that can be validated using a schema. These include string, number, boolean, null, and undefined. You can create a Zod schema for any TypeScript primitive using the corresponding method provided by Zod. For example, z.string() creates a schema that validates strings, and z.number() creates a schema that validates numbers. This example shows string validation:

import { z } from "zod";

const dataInput = z.string().min(8).max(20);
dataInput.parse("A long text");
dataInput.parse("AbC"); // error

Zod includes a handful of string-specific and number-specific validations.

z.number().gt(10);
z.number().gte(10); // alias .min(5)
z.number().lt(10);
z.number().lte(10); // alias .max(5)

z.number().int(); // value must be an integer

z.string().email();
z.string().url();
z.string().emoji();
z.string().uuid();
z.string().cuid();
z.string().cuid2();
z.string().regex(regex);
z.string().startsWith(string);
z.string().endsWith(string);
z.string().trim(); // trim whitespace
z.string().datetime(); // defaults to UTC
z.string().ip();

Objects in Zod

Zod provides primitives for validating objects with specific properties and their types. You can create a Zod schema for an object using the z.object() method. Here's an example of how to validate an address object using Zod in TypeScript:

import * as z from 'zod';

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string().length(2),
  zip: z.string().regex(/^\d{5}$/),
});

type Address = z.infer<typeof addressSchema>;

function validateAddress(address: Address): void {
  try {
    addressSchema.parse(address);
    console.log('Address is valid');
  } catch (error) {
    console.error('Address is invalid:', error.message);
  }
}

const validAddress: Address = {
  street: '123 Main St',
  city: 'Sometown',
  state: 'CA',
  zip: '34578',
};

const invalidAddress: Address = {
  street: '',
  city: '',
  state: 'California',
  zip: '1234',
};

validateAddress(validAddress); // prints "Address is valid"
validateAddress(invalidAddress); // prints "Address is invalid" with details about the validation failure

This code defines an addressSchema object using the z.object() function provided by Zod, which specifies the validation constraints for a valid address object.

The type Address = z.infer<typeof addressSchema> line defines a type alias for the inferred type of the addressSchema object, which is equivalent to { street: string, city: string, state: string, zip: string }.

The validateAddress function takes an address parameter of type Address, and attempts to parse it using the addressSchema.parse() method. If parsing succeeds, it logs a message indicating that the address is valid. If parsing fails due to a validation error, it logs an error message with details about the validation failure.

The code then demonstrates how to use the validateAddress function to validate two sample address objects (validAddress and invalidAddress). The first call to validateAddress() succeeds and logs a message indicating that the address is valid. The second call to validateAddress() fails due to some of the properties not meeting the validation constraints and logs an error message with details about the validation failure.

Complex schema objects

To write a complex schema validation in Zod, you can use the z.object() method to define an object schema with specific properties and their types. Then, you can use other methods like z.array(), z.union(), and z.optional() to define arrays, optional properties, and combined schemas. For example, consider the following code:

import { z } from 'zod';

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  state: z.string(),
  zip: z.string().min(5),
});

const userSchema = z.object({
  name: z.string(),
  age: z.number(),
  email: z.string().email(),
  address: addressSchema,
  phoneNumbers: z.array(z.string().min(10)),
});

const adminSchema = userSchema.extend({
  role: z.literal('admin'),
});

const userOrAdminSchema = z.union([userSchema, adminSchema]);

type UserOrAdmin = z.infer<typeof userOrAdminSchema>;

const user: UserOrAdmin = {
  name: 'John Doe',
  age: 30,
  email: 'johndoe@example.com',
  address: {
    street: '123 Main St',
    city: 'Sometown',
    state:'CA',
    zip:'34578',
   },
   phoneNumbers:[
     '555-555-5555',
     '666-666-6666'
   ],
};

const admin: UserOrAdmin = {
   name:'Elena Smith',
   age :40,
   email:'janesmith@example.com',
   address:{
     street:'456 Second St',
     city:'Othertown', 
     state:'NY', 
     zip:'67890'
   },
   phoneNumbers:[
     '777-777-7777'
   ],
   role:'admin'
};

In this example, addressSchema is defined as an object schema with specific properties and their types. Then, userSchema is defined as an object schema that includes the address property with its own schema. The adminSchema extends the userSchema by adding a new property called role. Finally, the userOrAdminSchema is defined as a union of both schemas. The UserOrAdmin type is then used to define variables with properties that match either schema.

Type inference in Zod

To write a type inference in Zod, you can use the infer keyword with the typeof operator to extract the inferred type from a Zod schema. For example, consider the following code:

import { z } from 'zod';

const userSchema = z.object({
  name: z.string(),
  age: z.number(),
});

const adminSchema = z.object({
  name: z.string(),
  role: z.literal('admin'),
});

const userOrAdminSchema = z.union([userSchema, adminSchema]);

type UserOrAdmin = z.infer<typeof userOrAdminSchema>;

const user: UserOrAdmin = {
  name: 'John Doe',
  age: 30,
};

const admin: UserOrAdmin = {
  name: 'Jonathan Smith',
  role: 'admin',
};

In this example, z.union([userSchema, adminSchema]) creates a schema that validates either a user object or an admin object. The UserOrAdmin type is then used to define variables with properties that match either schema.

Customizing error messages

Zod allows you to customize some common error messages when creating a schema. For example, you can customize the error message for a required field in a number schema using the required_error option:

const age = z.number({
  required_error: "Age is required",
  invalid_type_error: "Age must be a number",
  invalid_age: "Age must be larger than 13",
});

This will override the default error message for a missing value in the number schema.

Zod's errorMap method allows you to map specific errors to custom messages. This can be useful for providing more descriptive error messages or translating error messages into different languages. To use the errorMap method, you can define a function that takes an error object and returns a custom message for each error path. For example, the following code creates a schema that validates an email address and maps the default error message to a custom message.

import { z } from 'zod';

const emailSchema = z.string().email().errorMap((error) => {
  if (error.path === 'email') {
    return { message: 'Please enter a valid email address' };
  }
  return { message: 'Invalid input' };
});

This schema can then be used to validate input data during runtime. If the input data does not match the schema, an error will be thrown with the custom error message.

Conclusion

In conclusion, Zod provides several benefits such as static type inference for data structures, flexibility in schema design, minimal code requirements for validation, prevention of garbage values from being sent to databases, and developer-friendly error handling mechanisms that avoid common human errors in validators.