Using Zod to validate data in NestJS
Introduction
- This article will guide you on using
Zodcombined withDrizzle ORMto validate data such as payload body, url param, object and more. - If you are using
Drizzle ORM,Zodis almost the overwhelming choice thanks to its ability to automatically generateSchemafrom theDatabase, completely eliminating duplicate code typing and making the application type-safe.
Besides using Zod, we also need to combine additional packages as follows
drizzle-zodto automatically generateZod schemafromDatabase 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-zodto convert that Zod schema intoDTOand validate input data inNestJS 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-zodbrings the light weight and power of Zod into NestJS. - Main features:
- Create DTO from Zod Schema: Converts Zod schema into NestJS DTO class via
createZodDtofunction. - Automatic validation Pipe: Provides
ZodValidationPipeto 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.
- Create DTO from Zod Schema: Converts Zod schema into NestJS DTO class via
- This library integrates Zod into the NestJS framework to replace or supplement the traditional pair
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 addnotNull()to specify which columns cannot be empty - I have added constraints
- To check min/max value for
INTEGER, min/max length forTEXT - Check if
jsonCol(JSONB Object type) has sufficient keys['isActive', 'name', 'value']using PostgreSQL operator?&. - Column
jsonArrayColchecks similar fields asjsonColbut for object array
- To check min/max value for
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 TypeorInferInsertModelfromdrizzle-ormto create corresponding interface from schema - Or use
testTable.$inferSelect, testTable.$inferInsertdirectly fromdrizzle table schemafor the same result - Note that even though I added Constraint in table schema, using
drizzle-zoddoes 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()orserial()(such as id, createdAt, enumCol) are required because data from DB will definitely have these values. - Columns without
notNull()will have typenullable()(allowingnull, notundefined). - Then use the
createZodDtofunction of nestjs-zod to convert into DTO to validate as usual
- Columns with
- When using
createInsertSchema(testTable)to create a zod type for adding new dataserial()columns or columns with.default()will convert tooptional()because Database can generate values automatically if client does not send themnestjs-zodalso supports many functions such asextendto expand fields to check andomitto 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.objectand create DTO as usual
- All columns convert to
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
createInsertSchema
If no payload is passed, an error will be reported
If an empty object is passed, required fields will report errors
By default, using drizzle-zod does not automatically add Constraints, so it will bypass JSON payload validation, but inserting into Postgres will report an error
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 jsonArrayCol
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 succeed
If data is invalid, you will see full error messages for each field
{
"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 field
When using POST /test/update-2, I created another zod type (updateTestSchema2) validating fields, so passing invalid payload will report corresponding errors
{
"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 fields
{
"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 allowed
Happy coding!
Comments
Post a Comment