Using Zod to validate data in NestJS

Introduction

  • This article will guide you on using Zod combined with Drizzle ORM to validate data such as payload body, url param, object and more.
  • If you are using Drizzle ORM, Zod is almost the overwhelming choice thanks to its ability to automatically generate Schema from the Database, completely eliminating duplicate code typing and making the application type-safe.
alt text

Besides using Zod, we also need to combine additional packages as follows

  • drizzle-zod to automatically generate Zod schema from Database Tables.
    • Helps automatically generate Zod Schemas directly from tables defined in Drizzle ORM.
    • Avoids defining data twice (once in the database table in Drizzle, once creating Zod Schema to validate request body).
    • Main features:
      • Automatic mapping: Converts Drizzle data types (string, number, boolean, date, enum) into corresponding Zod schema.
      • Flexible schema creation: Supports generating schema for inserted data (createInsertSchema), queried data (createSelectSchema) or updated data (createUpdateSchema).
      • Customization: Allows overriding or extending validation rules for specific fields.
  • nestjs-zod to convert that Zod schema into DTO and validate input data in NestJS Controllers.
    • This library integrates Zod into the NestJS framework to replace or supplement the traditional pair class-validator + class-transformer.
    • Solved problem: NestJS defaults to Class Decorator (class-validator) to validate DTO, which is sometimes verbose and difficult to share types with Frontend. nestjs-zod brings the light weight and power of Zod into NestJS.
    • Main features:
      • Create DTO from Zod Schema: Converts Zod schema into NestJS DTO class via createZodDto function.
      • Automatic validation Pipe: Provides ZodValidationPipe to validate request (body, query, param) before reaching the Controller.
      • Swagger/OpenAPI support: Automatically generates Swagger UI documentation from Zod schema without manually writing @ApiProperty() decorators.

Detail

Install the following packages

bun add zod drizzle-zod nestjs-zod

Modify file drizzle-orm/schema.ts

import {sql} from 'drizzle-orm'
import {
  bigserial,
  check,
  integer,
  jsonb,
  pgEnum,
  pgTable,
  serial,
  text,
  timestamp,
  varchar,
} from 'drizzle-orm/pg-core'

export const valueEnum = pgEnum('enum_value', ['value 1', 'value 2', 'value 3'])

export interface CustomItem {
  isActive: boolean
  name: string
  value: number
}

export const testTable = pgTable(
  'test_table',
  {
    id: serial('id').primaryKey(),

    enumCol: valueEnum('enum_col').default('value 1').notNull(),

    numCol: integer('num_col').notNull(),
    minNumCol: integer('min_num_col'),
    maxNumCol: integer('max_num_col'),

    textCol: text('text_col').notNull(),
    minTextCol: text('min_text_col'),
    maxTextCol: text('max_text_col'),

    jsonCol: jsonb('json_col').$type<CustomItem>(),
    jsonArrayCol: jsonb('json_array_col').$type<CustomItem[]>(),
  },
  table => [
    check(
      'num_col_range_check',
      sql`${table.numCol} IS NULL OR (${table.numCol} >= 10 AND${table.numCol} <= 100)`
    ),
    check(
      'min_num_check',
      sql`${table.minNumCol} IS NULL OR${table.minNumCol} >= 10`
    ),
    check(
      'max_num_check',
      sql`${table.maxNumCol} IS NULL OR${table.maxNumCol} <= 100`
    ),

    check(
      'text_col_length_check',
      sql`${table.textCol} IS NULL OR (char_length(${table.textCol}) >= 5 AND char_length(${table.textCol}) <= 200)`
    ),
    check(
      'min_text_length_check',
      sql`${table.minTextCol} IS NULL OR char_length(${table.minTextCol}) >= 5`
    ),
    check(
      'max_text_length_check',
      sql`${table.maxTextCol} IS NULL OR char_length(${table.maxTextCol}) <= 200`
    ),

    check(
      'json_col_required_fields_check',
      sql`${table.jsonCol} IS NULL OR${table.jsonCol} ?& array['isActive', 'name', 'value']`
    ),

    check(
      'json_array_col_elements_check',
      sql`${table.jsonArrayCol} IS NULL OR NOT jsonb_path_exists(${table.jsonArrayCol},
        '$[*] ? (!exists(@.isActive) || !exists(@.name) || !exists(@.value))'
      )`
    ),
  ]
)
  • When using Dizzle Table Schema, all data columns are nullable by default, you can add notNull() to specify which columns cannot be empty
  • I have added constraints
    • To check min/max value for INTEGER, min/max length for TEXT
    • Check if jsonCol (JSONB Object type) has sufficient keys ['isActive', 'name', 'value'] using PostgreSQL operator ?&.
    • Column jsonArrayCol checks similar fields as jsonCol but for object array

Create file dto/test.dto.ts

import {InferInsertModel, InferSelectModel} from 'drizzle-orm'
import {
  createInsertSchema,
  createSelectSchema,
  createUpdateSchema,
} from 'drizzle-zod'
import {createZodDto} from 'nestjs-zod'
import {testTable} from 'src/drizzle-orm/schema'
import {z} from 'zod'

export type Test = InferSelectModel<typeof testTable>
export type Test2 = typeof testTable.$inferSelect

export type NewTest = InferInsertModel<typeof testTable>
export type NewTest2 = typeof testTable.$inferInsert

const customItemZodSchema = z.object({
  isActive: z.boolean(),
  name: z.string().min(2, 'Min length is 2').max(50, 'Max length is 50'),
  value: z.number().min(0, 'Min value is 0').max(1000, 'Max value is 1000'),
})

export const selectTestSchema = createSelectSchema(testTable)

export const insertTestSchema1 = createInsertSchema(testTable)
export const insertTestSchema2 = createInsertSchema(testTable, {
  numCol: schema => schema.min(10).max(100),
  minNumCol: schema => schema.min(10).optional(),
  maxNumCol: schema => schema.max(100).optional(),

  textCol: () => z.string().min(5).max(200),
  minTextCol: z.string().min(5).optional(),
  maxTextCol: z.string().max(200).optional(),

  jsonCol: customItemZodSchema.optional(),
  jsonArrayCol: z.array(customItemZodSchema).min(1),
})
  .extend({
    colBool: z.boolean(),
    colBool2: z.boolean(),
    jsonArrayCol2: z.array(customItemZodSchema).min(2).optional(),
  })
  .omit({
    colBool2: true,
  })

export const updateTestSchema1 = createUpdateSchema(testTable)
export const updateTestSchema2 = z.object({
  numCol: z.number().min(10).max(100),
  minNumCol: z.number().min(10).optional(),
  maxNumCol: z.number().max(10).optional(),

  textCol: z.string().min(5).max(200),
  minTextCol: z.string().min(5).optional(),
  maxTextCol: z.string().max(200).optional(),

  jsonCol: customItemZodSchema.optional(),
  jsonArrayCol: z.array(customItemZodSchema).min(1),
})

export class TestResponseDto extends createZodDto(selectTestSchema) {}

export class CreateTestDto1 extends createZodDto(insertTestSchema1) {}
export class CreateTestDto2 extends createZodDto(insertTestSchema2) {}

export class UpdateTestDto1 extends createZodDto(updateTestSchema1) {}
export class UpdateTestDto2 extends createZodDto(updateTestSchema2) {}
  • You can use Utility Type or InferInsertModel from drizzle-orm to create corresponding interface from schema
  • Or use testTable.$inferSelect, testTable.$inferInsert directly from drizzle table schema for the same result
  • Note that even though I added Constraint in table schema, using drizzle-zod does not automatically transfer those Constraints for validation, we still have to validate each column separately, but it still guarantees type-safety because data types and column names are fixed, so you will not have to define them in multiple places or miswrite this information (because the compiler will report an error)
  • When using createSelectSchema(testTable)
    • Columns with .default() or serial() (such as id, createdAt, enumCol) are required because data from DB will definitely have these values.
    • Columns without notNull() will have type nullable() (allowing null, not undefined).
    • Then use the createZodDto function of nestjs-zod to convert into DTO to validate as usual
  • When using createInsertSchema(testTable) to create a zod type for adding new data
    • serial() columns or columns with .default() will convert to optional() because Database can generate values automatically if client does not send them
    • nestjs-zod also supports many functions such as extend to expand fields to check and omit to remove unneeded fields
  • When using createUpdateSchema(testTable) to create a zod type for editing
    • All columns convert to optional() to support partial updates (only sending fields that need editing).
    • You can also define your own zod type with z.object and create DTO as usual

Create file controller/test.controller.ts to use created DTOs

import {Body, Controller, Post} from '@nestjs/common'
import {testTable} from 'src/drizzle-orm/schema'
import {
  CreateTestDto1,
  CreateTestDto2,
  TestResponseDto,
  UpdateTestDto1,
  UpdateTestDto2,
} from 'src/dto/test.dto'
import {DrizzleService} from 'src/service/drizzle.service'

@Controller('test')
export class TestController {
  constructor(private readonly drizzle: DrizzleService) {}

  async create(dto: CreateTestDto1) {
    const [result] = await this.drizzle.db
      .insert(testTable)
      .values(dto)
      .returning()
    return result
  }

  @Post('response')
  response(@Body() dto: TestResponseDto) {
    return dto
  }

  @Post('create-1')
  async create1(@Body() dto: CreateTestDto1) {
    return this.create(dto)
  }

  @Post('create-2')
  async create2(@Body() dto: CreateTestDto2) {
    return this.create(dto)
  }

  @Post('update-1')
  update1(@Body() dto: UpdateTestDto1) {
    return {message: 'updated', dto}
  }

  @Post('update-2')
  update2(@Body() dto: UpdateTestDto2) {
    return {message: 'updated', dto}
  }
}

Modify file app.module.ts to import ZodValidationPipe and DrizzleService as guided in the previous article

import {Module} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {APP_PIPE} from '@nestjs/core'
import {ZodValidationPipe} from 'nestjs-zod'
import {TestController} from './controller/test.controller'
import {DrizzleService} from './service/drizzle.service'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
  ],
  controllers: [
    TestController,
  ],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ZodValidationPipe,
    },
    DrizzleService,
  ],
})
export class AppModule {}

Then push changes from schema into Postgres

$ bun db:push
$ drizzle-kit push
No config path provided, using default 'drizzle.config.ts'
Reading config file '~/drizzle.config.ts'
Using 'pg' driver for database querying
[✓] Pulling schema from database...
[✓] Changes applied

The result is as follows

alt text

createInsertSchema

If no payload is passed, an error will be reportedalt text

If an empty object is passed, required fields will report errorsalt text

By default, using drizzle-zod does not automatically add Constraints, so it will bypass JSON payload validation, but inserting into Postgres will report an erroralt text

You can check the log to see violates check constraint error as follows

query: 'insert into "test_table" ("id", "enum_col", "num_col", "min_num_col", "max_num_col", "text_col", "min_text_col", "max_text_col", "json_col", "json_array_col") values (default, default, $1, default, default, $2, default, default, default, default) returning "id", "enum_col", "num_col", "min_num_col", "max_num_col", "text_col", "min_text_col", "max_text_col", "json_col", "json_array_col"',
  params: [
    1000,
    '12'
  ],
  cause: error: new row for relation "test_table" violates check constraint "num_col_range_check"

Similarly when missing required field for jsonArrayColalt text

query: 'insert into "test_table" ("id", "enum_col", "num_col", "min_num_col", "max_num_col", "text_col", "min_text_col", "max_text_col", "json_col", "json_array_col") values (default, default, $1, default, default, $2, default, default, default, $3) returning "id", "enum_col", "num_col", "min_num_col", "max_num_col", "text_col", "min_text_col", "max_text_col", "json_col", "json_array_col"',
  params: [
    100,
    '12345',
    '[{"isActive":true,"name":"123"}]'
  ],
  cause: error: new row for relation "test_table" violates check constraint "json_array_col_elements_check"

If using POST /test/create-2 with fully validated payload, insertion will succeedalt text

If data is invalid, you will see full error messages for each fieldalt text

{
    "statusCode": 400,
    "message": "Validation failed",
    "errors": [
        {
            "origin": "number",
            "code": "too_big",
            "maximum": 100,
            "inclusive": true,
            "path": [
                "numCol"
            ],
            "message": "Too big: expected number to be <=100"
        },
        {
            "origin": "number",
            "code": "too_small",
            "minimum": 10,
            "inclusive": true,
            "path": [
                "minNumCol"
            ],
            "message": "Too small: expected number to be >=10"
        },
        {
            "origin": "number",
            "code": "too_big",
            "maximum": 100,
            "inclusive": true,
            "path": [
                "maxNumCol"
            ],
            "message": "Too big: expected number to be <=100"
        },
        {
            "origin": "string",
            "code": "too_small",
            "minimum": 5,
            "inclusive": true,
            "path": [
                "textCol"
            ],
            "message": "Too small: expected string to have >=5 characters"
        },
        {
            "origin": "string",
            "code": "too_small",
            "minimum": 5,
            "inclusive": true,
            "path": [
                "minTextCol"
            ],
            "message": "Too small: expected string to have >=5 characters"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "maxTextCol"
            ],
            "message": "Invalid input: expected string, received number"
        },
        {
            "expected": "boolean",
            "code": "invalid_type",
            "path": [
                "jsonCol",
                "isActive"
            ],
            "message": "Invalid input: expected boolean, received undefined"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "jsonCol",
                "name"
            ],
            "message": "Invalid input: expected string, received undefined"
        },
        {
            "expected": "number",
            "code": "invalid_type",
            "path": [
                "jsonCol",
                "value"
            ],
            "message": "Invalid input: expected number, received undefined"
        },
        {
            "origin": "string",
            "code": "too_small",
            "minimum": 2,
            "inclusive": true,
            "path": [
                "jsonArrayCol",
                0,
                "name"
            ],
            "message": "Min length is 2"
        },
        {
            "expected": "boolean",
            "code": "invalid_type",
            "path": [
                "colBool"
            ],
            "message": "Invalid input: expected boolean, received undefined"
        },
        {
            "origin": "array",
            "code": "too_small",
            "minimum": 2,
            "inclusive": true,
            "path": [
                "jsonArrayCol2"
            ],
            "message": "Too small: expected array to have >=2 items"
        }
    ]
}

createUpdateSchema

When using createUpdateSchema, all fields are converted to optional, so it only validates whether you pass payload data or not, without checking each specific fieldalt textalt text

When using POST /test/update-2, I created another zod type (updateTestSchema2) validating fields, so passing invalid payload will report corresponding errorsalt text

{
    "statusCode": 400,
    "message": "Validation failed",
    "errors": [
        {
            "origin": "number",
            "code": "too_big",
            "maximum": 100,
            "inclusive": true,
            "path": [
                "numCol"
            ],
            "message": "Too big: expected number to be <=100"
        },
        {
            "origin": "number",
            "code": "too_small",
            "minimum": 10,
            "inclusive": true,
            "path": [
                "minNumCol"
            ],
            "message": "Too small: expected number to be >=10"
        },
        {
            "origin": "number",
            "code": "too_big",
            "maximum": 100,
            "inclusive": true,
            "path": [
                "maxNumCol"
            ],
            "message": "Too big: expected number to be <=100"
        },
        {
            "origin": "string",
            "code": "too_small",
            "minimum": 5,
            "inclusive": true,
            "path": [
                "textCol"
            ],
            "message": "Too small: expected string to have >=5 characters"
        },
        {
            "origin": "string",
            "code": "too_small",
            "minimum": 5,
            "inclusive": true,
            "path": [
                "minTextCol"
            ],
            "message": "Too small: expected string to have >=5 characters"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "maxTextCol"
            ],
            "message": "Invalid input: expected string, received number"
        },
        {
            "expected": "boolean",
            "code": "invalid_type",
            "path": [
                "jsonCol",
                "isActive"
            ],
            "message": "Invalid input: expected boolean, received undefined"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "jsonCol",
                "name"
            ],
            "message": "Invalid input: expected string, received undefined"
        },
        {
            "expected": "number",
            "code": "invalid_type",
            "path": [
                "jsonCol",
                "value"
            ],
            "message": "Invalid input: expected number, received undefined"
        },
        {
            "origin": "string",
            "code": "too_small",
            "minimum": 2,
            "inclusive": true,
            "path": [
                "jsonArrayCol",
                0,
                "name"
            ],
            "message": "Min length is 2"
        }
    ]
}

createSelectSchema

When using createSelectSchema, the generated zod type requires all fieldsalt text

{
    "statusCode": 400,
    "message": "Validation failed",
    "errors": [
        {
            "expected": "number",
            "code": "invalid_type",
            "path": [
                "id"
            ],
            "message": "Invalid input: expected number, received undefined"
        },
        {
            "code": "invalid_value",
            "values": [
                "value 1",
                "value 2",
                "value 3"
            ],
            "path": [
                "enumCol"
            ],
            "message": "Invalid option: expected one of \"value 1\"|\"value 2\"|\"value 3\""
        },
        {
            "expected": "number",
            "code": "invalid_type",
            "path": [
                "numCol"
            ],
            "message": "Invalid input: expected number, received undefined"
        },
        {
            "expected": "number",
            "code": "invalid_type",
            "path": [
                "minNumCol"
            ],
            "message": "Invalid input: expected number, received undefined"
        },
        {
            "expected": "number",
            "code": "invalid_type",
            "path": [
                "maxNumCol"
            ],
            "message": "Invalid input: expected number, received undefined"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "textCol"
            ],
            "message": "Invalid input: expected string, received undefined"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "minTextCol"
            ],
            "message": "Invalid input: expected string, received undefined"
        },
        {
            "expected": "string",
            "code": "invalid_type",
            "path": [
                "maxTextCol"
            ],
            "message": "Invalid input: expected string, received undefined"
        }
    ]
}

For columns not using notNull(), you can pass null, but passing undefined (or omitting the field) is not allowedalt text

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Understanding React Server Component

Kubernetes Deployment for Zero Downtime

Sitemap

Deploying a NodeJS Server on Google Kubernetes Engine

React Practice Series

Helm for beginer - Deploy nginx to Google Kubernetes Engine

Docker Practice Series

A Handy Guide to Using Dynamic Import in JavaScript

DevOps Practice Series