JavaScript

Data Validation with Zod

A list of client-side data validations with Zod

  • #validation
  • #typescript

Zod is a TypeScript library for input validation. TypeScript has done a profound job on ensuring type safety for JavaScript development at compile time. Zod complements TypeScript by providing runtime validation such as form validation in the browser.

Basic Usage

The sample below tries to parse the user object against the Zod object schema called UserSchema. If the validation is successful, it will return the object itself otherwise it will throw an Error.

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  username: z.string(),
  email: z.string().email(),
})

const user = { id: 1, username: 0, email: 'abcd' }

console.log(UserSchema.parse(user))

Infer Schema

Zod's schema can be also used for type inference, which is extremely handy for code reuse.

const UserSchema = z.object({
  id: z.number(),
  username: z.string(),
  email: z.string().email(),
})

type User = z.infer<typeof UserSchema>

const user: User = {
  id: 123,
  username: 'Jason',
  email: 'json@gmail.com',
}

console.log(UserSchema.parse(user)) // exactly the `user` object

Safe Parse

Instead of throwing an error when the validation fails, it wraps the result into an object with a success property that indicates the state of validation. The error property can only be accessed when the validation fails whereas the data property is only made available when the validation passes.

const result = UserSchema.parse(user)

if (!result.success) {
  console.log(result.error)
} else {
  console.log(result.data as User)
}

Primitive Types

The basic primitive type includes z.object, z.string, z.number, z.date and z.boolean.

const UserSchema = z.object({
  username: z.string(),
  age: z.number(),
  birthday: z.date(),
  isProgrammer: z.boolean(),
})

Special Types

The additional types that deals with falsy values in JavaScript.

z.object({
  undefined_type: z.undefined(),
  null_type: z.null(),
  void_type: z.void(),
  any_type: z.any(),
  unknown_type: z.unknown(), // can be used the same with `any`
  never_type: z.never(), // never have this 'key' in the object else it will fail
})

Modifiers

The additional logic that chains after the type definition to modify or refine the requirements of the field.

z.object({
  optional: z.string().optional(),
  nullable: z.string().nullable(), // can be 'null', which different from optional
  nullish: z.string().nullish(), // can be 'null' or 'undefined'
  default: z.string().default('hello'),
  randomNum: z.number().default(Math.random),
})

String Modifiers

const UserSchema = z.object({
  username: z.string().min(3).max(10), // length of character
  email: z.string().email(),
  url: z.string().url(),
})

More on string modifiers here.

Number Modifiers

const UserSchema = z.object({
  age: z.number().gt(0),
})

More on number modifiers here.

Literal Type

Specific value that matches exactly.

z.literal('sashimi')
z.literal(true)

More on literal here.

List of variants

With Zod Enum, its basically converts into TypeScript union type.

z.enum(['Programming', 'Guitar'])

When the array is declared somewhere outside the z.enum clause, it needs to have an additional as const keyword appended to the array.

const hobbies = ['Programming', 'Guitar'] as const
z.enum(hobbies) // no issue
console.log(hobbies[0])

This way Zod can make sure that the value does not change throughout the application to make precise validations.

Besides Zod Enum, native TypeScript Enum can also be supported with z.nativeEnum.

enum Hobbies {
  Programming,
  Guitar,
}

z.nativeEnum(Hobbies)

const ppl = {
  hobbies: Hobbies.Programming,
}

Object Type

To get the shape for the schema, use the shape property and it will tell which field is present and their corresponding data types.

UserSchema.shape

The partial modifier is useful for multistep form by making each field within the schema optional.

UserSchema.partial().parse(user)

Similar to RxJS's pluck, pick selects the specified key-value pairs from a larger object.

UserSchema.pick({ username: true }).parse(user)

To omit certain elements, use the omit method. The method signature is identical to pick.

UserSchema.omit({ username: true }).parse(user)

Deep partials allows the comparison for objects nested in another object as Zod only perform shallow checks by default.

UserSchema.deepPartial().parse(user)

Extend allows the original schema to be extended with additional fields into a new schema.

UserSchema.extend({ name: z.string() }).parse(user)

Merge an already existing schema with another schema together into a new schema.

UserSchema.merge(UserSchema2).parse(user)

Non-existing Key

By default, Zod omits all the key value pairs that are not defined in the schema. For example, if you have this object,

const UserSchema = z.object({
  username: z.string().min(5).max(15),
})

const user = {
  username: 'Jason', // defined in schema
  address: '41777 South Bound, West Virginia', // not defined in schema
}

The resulting object after parse will not have the address property.

console.log(UserSchema.parse(user))

To allow any of the undocumented fields to gets into the final output, use passthrough.

const UserSchema = z
  .object({
    username: z.string().min(5).max(15),
  })
  .passthrough()

Otherwise, strict mode will throw error if there is any provided key that does not exist within the original schema.

const UserSchema = z
  .object({
    username: z.string().min(5).max(15),
  })
  .strict()

Array Type

Array type can be declared with z.array and passing the type of value that it will hold.

z.array(z.string())

To make sure the array contain at least one value, use nonempty modifier.

z.array(z.string()).nonempty()

More on array here.

Tuple

To declare a tuple type, pass in an array of Zod type declarations into z.tuple method. The type declarations can be chained with modifiers just fine.

z.tuple([z.number(), z.number(), z.number().gt(4).int()])

To declare a tuple with indefinite length, rest can be used to indicate the type of the elements that are not mentioned in the declarations for the rest of params.

z.tuple([z.string(), z.date()]).rest(z.number())

Unions

Basically union type in TypeScript.

z.union([z.string(), z.number()]) // id
z.string().or(z.number()) // same

Discriminated union - The type of one field depends on the output of the previous defined field.

const UserSchema = z.object({
  id: z.discriminatedUnion('status', [
    z.object({ status: z.literal('success'), data: z.string() }),
    z.object({ status: z.literal('failed'), data: z.instanceof(Error) }),
  ]),
})

Record

The key-value pair validation for an object. The example below only validate the 'key' of the object that must be of type string.

const UserMap = z.record(z.string()) // keys only

const usermp = {
  abc: 'abc',
  def: 'def',
}

It is possible to validate both key and value pair of the object by providing two Zod type to the z.record method.

const UserMap = z.record(z.string(), z.number()) // keys and values

Map

This is highly resembles the record type above, but tailored to the actual JavaScript Map type itself.

const UserMap = z.map(z.string(), z.object({ name: z.string() }))

const users = new Map([
  ['id-jogn', { name: 'Jphn' }],
  ['id-kye', { name: 'Kyle' }],
])

Set

const UserMap = z.set(z.number())

const a = new Set([1, 1, 1, 2])

console.log(UserMap.parse(a)) // [1, 2]

Promise

const PromiseSchema = z.promise(z.string())

const p = Promise.resolve('ahaha') // 2 step validation

console.log(PromiseSchema.parse(p))

Refine

Gives low level access such as manipulating the error message that will be displayed when validation failed.

const brandEmail = z
  .string()
  .email()
  .refine((val) => val.endsWith('@email.com'), {
    message: 'Email must be end with @email.com',
  })

const email = 'email.com'
console.log(brandEmail.parse(email))

There is also a method called superRefine with gives very low level access for customization.

Errors

Error messages can be customized by providing it as a second argument into the targeted clause.

z.string().min(3, 'min length must be three')

Besides that, there is a validation error package provided by Zod that translates all the complicated error messages into a more human readable manner.

npm i zod-validation-error
import { fromZodError } from 'zod-validation-error'

if (!results.success) {
  console.log(fromZodError(results.error))
}

Reference

Cook, K. Learn Zod In 30 Minutes.https://www.youtube.com/watch?v=L6BE-U3oy80
Introduction.ย Zod. Retrieved 2024, March 25 from https://zod.dev/?id=introduction