NestJS嵌套数据校验详解
NestJS嵌套数据校验详解
学习目标:掌握嵌套数据校验、简单类型数组校验、复杂对象校验、数据转换、DTO 与 Interface 的区别。
一、嵌套数据校验概述
1.1 为什么需要嵌套数据校验
code
嵌套数据校验场景:
│
├── 简单场景
│ ├── 创建课程时同时关联多个标签
│ ├── 创建用户时同时创建个人资料
│ └── 创建订单时同时创建多个订单项
│
├── 数据结构示例
│ ├── 简单数组:tags: [1, 2, 3]
│ ├── 对象数组:items: [{ id: 1, name: '商品1' }]
│ └── 嵌套对象:profile: { bio: '简介', avatar: '头像' }
│
└── 校验需求
├── 数组元素的类型校验
├── 嵌套对象的属性校验
└── 嵌套数组的对象校验1.2 DTO vs Interface
code
DTO 与 Interface 的区别:
│
├── DTO(Data Transfer Object)
│ ├── 使用 class 定义
│ ├── 支持装饰器校验
│ ├── 运行时校验
│ └── 使用场景:Controller 层参数校验
│
├── Interface
│ ├── 使用 interface 定义
│ ├── 纯静态类型检查
│ ├── 编译时检查
│ └── 使用场景:Service 层内部类型定义
│
└── 选择建议
├── Controller 层:使用 DTO + class-validator
├── Service 层:使用 Interface
└── 前端传参:简化数据结构,后端转换二、简单类型数组校验
2.1 场景描述
code
前端传参结构:
│
├── 简化后的数据结构
│ {
│ "title": "课程标题",
│ "authorId": 1,
│ "tags": [1, 2, 3, 4] // 标签 ID 数组
│ }
│
├── 前端优势
│ ├── 数据结构简单
│ ├── 易于理解和使用
│ └── 减少传输数据量
│
└── 后端处理
├── 校验 tags 数组中的每个元素
├── 转换为嵌套创建所需的格式
└── 调用 Service 创建课程2.2 DTO 定义(使用 Interface)
typescript
// src/modules/course/dto/create-course-with-tags.dto.ts
import { IsString, IsNotEmpty, IsInt, IsOptional, IsArray } from 'class-validator';
import { CreateCourseDto } from './create-course.dto';
// 使用 Interface 定义 Service 层类型
export interface CreateCourseWithTagsInterface extends CreateCourseDto {
tags?: number[]; // 可选的标签 ID 数组
}
// 使用 Class 定义 Controller 层校验
export class CreateCourseWithTagsDto extends CreateCourseDto {
@IsOptional()
@IsArray()
@IsInt({ each: true }) // 校验数组中的每个元素都是整数
tags?: number[];
}2.3 @IsNumber 和 @IsString 的 each 选项
typescript
import { IsInt, IsString, IsArray } from 'class-validator';
// 校验数组中的每个元素都是整数
export class ExampleDto {
@IsArray()
@IsInt({ each: true }) // 正确用法
tagIds: number[];
}
// 校验数组中的每个元素都是字符串
export class ExampleDto2 {
@IsArray()
@IsString({ each: true }) // 正确用法
tags: string[];
}
// 错误用法
export class WrongDto {
@IsArray()
@IsInt([], { each: true }) // 第一个参数不是 options
tagIds: number[];
}2.4 each 选项详解
typescript
// IsInt 装饰器签名
@IsInt(options?: IsIntOptions, validationOptions?: ValidationOptions)
// IsIntOptions 接口
interface IsIntOptions {
allowNaN?: boolean; // 是否允许 NaN
maxDecimalDigits?: number; // 最大小数位数
}
// ValidationOptions 接口
interface ValidationOptions {
each?: boolean; // 是否对数组中的每个元素校验
message?: string; // 自定义错误消息
groups?: string[]; // 校验分组
}
// 正确使用方式
@IsInt({}, { each: true }) // 空对象 + each: true
@IsInt({ allowNaN: false }, { each: true }) // 带选项 + each: true三、Controller 层数据转换
3.1 创建 Controller
bash
# 使用 NestJS CLI 创建 Controller
npx nest g co modules/course
# 输出
CREATE src/modules/course/course.controller.ts
UPDATE src/modules/course/course.module.ts3.2 Controller 实现
typescript
// src/modules/course/course.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { CourseService } from './course.service';
import { CreateCourseWithTagsDto } from './dto/create-course-with-tags.dto';
@Controller('courses')
export class CourseController {
constructor(private readonly courseService: CourseService) {}
@Post()
async create(@Body() dto: CreateCourseWithTagsDto) {
// 判断是否有关联的标签
if (dto.tags && dto.tags.length > 0) {
// 转换数据格式:[1, 2, 3] → [{ tagId: 1 }, { tagId: 2 }, { tagId: 3 }]
const tags = {
create: dto.tags.map(tagId => ({ tagId })),
};
// 调用嵌套创建方法
return this.courseService.createCourseWithTags({
...dto,
tags,
});
} else {
// 没有关联标签,直接创建课程
return this.courseService.createCourse(dto);
}
}
}3.3 数据转换逻辑详解
code
数据转换流程:
│
├── 前端传参
│ {
│ "title": "课程标题",
│ "authorId": 1,
│ "tags": [1, 2, 3]
│ }
│
├── Controller 转换
│ ├── 判断:dto.tags && dto.tags.length > 0
│ ├── 转换:
│ │ tags: {
│ │ create: [
│ │ { tagId: 1 },
│ │ { tagId: 2 },
│ │ { tagId: 3 }
│ │ ]
│ │ }
│ └── 传递给 Service
│
└── Service 处理
└── Prisma 嵌套创建3.4 Service 方法调整
typescript
// src/modules/course/course.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { CreateCourseWithTagsInterface } from './dto/create-course-with-tags.dto';
@Injectable()
export class CourseService {
constructor(private prisma: PrismaService) {}
// 创建课程(基础)
async createCourse(dto: CreateCourseDto) {
return this.prisma.courses.create({
data: dto,
});
}
// 创建课程并关联标签(嵌套创建)
async createCourseWithTags(dto: CreateCourseWithTagsInterface) {
const { tags, ...courseData } = dto;
return this.prisma.courses.create({
data: {
...courseData,
tags, // 已经是正确的格式
},
include: {
tags: {
include: {
tag: true,
},
},
},
});
}
}四、复杂对象嵌套校验
4.1 使用 @ValidateNested 校验嵌套对象
typescript
// src/modules/course/dto/nested-validation.dto.ts
import { IsString, IsNotEmpty, IsInt, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
// 嵌套对象定义
export class ProfileDto {
@IsString()
@IsNotEmpty()
bio: string;
@IsString()
avatar: string;
}
// 主 DTO
export class CreateUserWithProfileDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
email: string;
// 嵌套对象校验
@ValidateNested()
@Type(() => ProfileDto) // 类型转换
profile: ProfileDto;
}4.2 @ValidateNested 和 @Type 详解
code
@ValidateNested 和 @Type 的作用:
│
├── @ValidateNested()
│ ├── 启用嵌套对象校验
│ ├── 校验嵌套对象的所有属性
│ └── 必须配合 @Type 使用
│
├── @Type(() => Class)
│ ├── 指定嵌套对象的类型
│ ├── 使用 class-transformer 进行转换
│ └── 将 JSON 对象转换为 Class 实例
│
└── 工作流程
├── 前端传递 JSON 对象
├── class-transformer 转换为 Class 实例
└── class-validator 校验实例的属性4.3 嵌套对象数组校验
typescript
// src/modules/course/dto/nested-array.dto.ts
import { IsString, IsNotEmpty, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
// 订单项
export class OrderItemDto {
@IsInt()
productId: number;
@IsInt()
quantity: number;
@IsString()
name: string;
}
// 创建订单
export class CreateOrderDto {
@IsInt()
userId: number;
// 嵌套对象数组校验
@IsArray()
@ValidateNested({ each: true }) // 校验数组中的每个对象
@Type(() => OrderItemDto)
items: OrderItemDto[];
}4.4 嵌套校验测试
测试一:嵌套对象校验
bash
# Postman 请求
POST /courses/test
# 请求数据(缺少 profile 属性)
{
"name": "John",
"email": "john@example.com"
}
# 响应(校验失败)
{
"statusCode": 400,
"message": ["profile must be an object"],
"error": "Bad Request"
}测试二:嵌套对象属性校验
bash
# 请求数据(profile 缺少必填属性)
{
"name": "John",
"email": "john@example.com",
"profile": {}
}
# 响应(校验失败)
{
"statusCode": 400,
"message": [
"profile.bio must be a string",
"profile.bio should not be empty"
],
"error": "Bad Request"
}测试三:嵌套对象数组校验
bash
# 请求数据(items 数组中有空对象)
{
"userId": 1,
"items": [
{
"productId": 1,
"quantity": 2,
"name": "商品1"
},
{} // 缺少必填属性
]
}
# 响应(校验失败)
{
"statusCode": 400,
"message": [
"items[1].productId must be an integer",
"items[1].quantity must be an integer",
"items[1].name must be a string"
],
"error": "Bad Request"
}五、完整实战示例
5.1 项目结构
code
src/modules/course/
├── course.module.ts
├── course.service.ts
├── course.controller.ts
└── dto/
├── create-course.dto.ts
├── create-course-with-tags.dto.ts
└── nested-validation.dto.ts5.2 完整 DTO 定义
typescript
// src/modules/course/dto/create-course.dto.ts
import { IsString, IsNotEmpty, IsInt } from 'class-validator';
export class CreateCourseDto {
@IsString()
@IsNotEmpty()
title: string;
@IsInt()
authorId: number;
}typescript
// src/modules/course/dto/create-course-with-tags.dto.ts
import { IsOptional, IsArray, IsInt } from 'class-validator';
import { CreateCourseDto } from './create-course.dto';
// Interface 用于 Service 层
export interface CreateCourseWithTagsInterface extends CreateCourseDto {
tags?: {
create: { tagId: number }[];
};
}
// Class 用于 Controller 层校验
export class CreateCourseWithTagsDto extends CreateCourseDto {
@IsOptional()
@IsArray()
@IsInt({}, { each: true }) // 校验数组中的每个元素
tags?: number[];
}5.3 完整 Controller 实现
typescript
// src/modules/course/course.controller.ts
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
import { CourseService } from './course.service';
import { CreateCourseWithTagsDto } from './dto/create-course-with-tags.dto';
@Controller('courses')
export class CourseController {
constructor(private readonly courseService: CourseService) {}
// 创建课程
@Post()
async create(@Body() dto: CreateCourseWithTagsDto) {
// 判断是否有关联的标签
if (dto.tags && dto.tags.length > 0) {
// 转换数据格式
const tags = {
create: dto.tags.map(tagId => ({ tagId })),
};
// 嵌套创建
return this.courseService.createCourseWithTags({
...dto,
tags,
});
} else {
// 普通创建
return this.courseService.createCourse(dto);
}
}
// 查询课程详情
@Get(':id')
async findOne(@Param('id') id: number) {
return this.courseService.getCourseById(id);
}
}5.4 完整 Service 实现
typescript
// src/modules/course/course.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/prisma/prisma.service';
import { CreateCourseDto } from './dto/create-course.dto';
import { CreateCourseWithTagsInterface } from './dto/create-course-with-tags.dto';
@Injectable()
export class CourseService {
constructor(private prisma: PrismaService) {}
// 创建课程(基础)
async createCourse(dto: CreateCourseDto) {
return this.prisma.courses.create({
data: dto,
});
}
// 创建课程并关联标签(嵌套创建)
async createCourseWithTags(dto: CreateCourseWithTagsInterface) {
const { tags, ...courseData } = dto;
return this.prisma.courses.create({
data: {
...courseData,
tags,
},
include: {
tags: {
include: {
tag: true,
},
},
},
});
}
// 查询课程详情
async getCourseById(id: number) {
return this.prisma.courses.findUnique({
where: { id },
include: {
author: true,
tags: {
include: {
tag: true,
},
},
},
});
}
}六、Postman 测试示例
6.1 测试一:校验基础属性
bash
# 请求
POST /courses
Content-Type: application/json
{
"title": "Vue3 项目实战",
"authorId": 1
}
# 响应(成功)
{
"id": 8,
"title": "Vue3 项目实战",
"authorId": 1,
"createdAt": "2024-01-01T00:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"
}6.2 测试二:校验数组元素类型
bash
# 请求(tags 数组中包含字符串)
POST /courses
Content-Type: application/json
{
"title": "Vue3 项目实战",
"authorId": 1,
"tags": ["1", "2", "3"] // 字符串类型
}
# 响应(校验失败)
{
"statusCode": 400,
"message": [
"each value in tags must be an integer number"
],
"error": "Bad Request"
}
# 修正后(tags 数组为数字)
{
"title": "Vue3 项目实战",
"authorId": 1,
"tags": [1, 2, 3] // 数字类型
}
# 响应(成功)
{
"id": 9,
"title": "Vue3 项目实战",
"authorId": 1,
"tags": [
{ "id": 1, "courseId": 9, "tagId": 1 },
{ "id": 2, "courseId": 9, "tagId": 2 },
{ "id": 3, "courseId": 9, "tagId": 3 }
]
}6.3 测试三:验证嵌套创建结果
bash
# 查询数据库验证
SELECT * FROM course_tags WHERE courseId = 9;
# 结果
+----+-----------+-------+
| id | courseId | tagId |
+----+-----------+-------+
| 1 | 9 | 1 |
| 2 | 9 | 2 |
| 3 | 9 | 3 |
+----+-----------+-------+
# 说明嵌套创建成功七、嵌套校验装饰器对比
7.1 简单类型数组校验
typescript
// 数组元素为整数
@IsArray()
@IsInt({}, { each: true })
tagIds: number[];
// 数组元素为字符串
@IsArray()
@IsString({ each: true })
tags: string[];
// 数组元素为布尔值
@IsArray()
@IsBoolean({ each: true })
flags: boolean[];7.2 复杂对象嵌套校验
typescript
// 嵌套单个对象
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;
// 嵌套对象数组
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];7.3 装饰器对比总结
| 校验类型 | 装饰器 | 使用场景 | 示例 |
|---|---|---|---|
| 简单数组 | @IsInt({ each: true }) | 数组元素为基本类型 | tagIds: number[] |
| 嵌套对象 | @ValidateNested() + @Type() | 嵌套对象校验 | profile: ProfileDto |
| 嵌套数组 | @ValidateNested({ each: true }) + @Type() | 数组中的对象校验 | items: OrderItemDto[] |
八、常见问题与解决方案
8.1 each 选项位置错误
问题:@IsInt([], { each: true }) 报错。
原因:第一个参数不是 options,而是 IsIntOptions。
解决方案:
typescript
// 错误
@IsInt([], { each: true })
// 正确
@IsInt({}, { each: true })
// 带选项
@IsInt({ allowNaN: false }, { each: true })8.2 @ValidateNested 不生效
问题:嵌套校验不生效,校验通过但应该失败。
原因:缺少 @Type() 装饰器。
解决方案:
typescript
// 错误:缺少 @Type
@ValidateNested()
profile: ProfileDto;
// 正确:添加 @Type
@ValidateNested()
@Type(() => ProfileDto)
profile: ProfileDto;8.3 嵌套数组校验失败
问题:数组中的对象校验不生效。
原因:缺少 { each: true } 选项。
解决方案:
typescript
// 错误:缺少 { each: true }
@IsArray()
@ValidateNested()
@Type(() => OrderItemDto)
items: OrderItemDto[];
// 正确:添加 { each: true }
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];8.4 数据转换时机问题
问题:前端传参格式与 Prisma 嵌套创建格式不匹配。
解决方案:
typescript
// 前端传参
{
"title": "课程标题",
"authorId": 1,
"tags": [1, 2, 3]
}
// Controller 转换
const tags = {
create: dto.tags.map(tagId => ({ tagId }))
};
// 转换后
{
"title": "课程标题",
"authorId": 1,
"tags": {
"create": [
{ "tagId": 1 },
{ "tagId": 2 },
{ "tagId": 3 }
]
}
}九、最佳实践总结
9.1 DTO 设计原则
code
DTO 设计最佳实践:
│
├── 1. 分离关注点
│ ├── Controller 层:DTO(带校验装饰器)
│ └── Service 层:Interface(纯类型定义)
│
├── 2. 简化前端传参
│ ├── 避免暴露内部结构
│ ├── 使用简化的数据格式
│ └── 后端负责数据转换
│
├── 3. 合理使用嵌套校验
│ ├── 简单数组:@IsInt({ each: true })
│ ├── 嵌套对象:@ValidateNested() + @Type()
│ └── 嵌套数组:@ValidateNested({ each: true }) + @Type()
│
└── 4. 明确校验时机
├── Controller 层:ValidationPipe 校验
└── Service 层:静态类型检查9.2 数据转换最佳实践
code
数据转换最佳实践:
│
├── 1. Controller 层负责转换
│ ├── 接收前端简化的数据格式
│ ├── 转换为 Service 所需格式
│ └── 保持 Service 层纯粹
│
├── 2. 转换逻辑清晰
│ ├── 使用 map 转换数组
│ ├── 解构赋值简化代码
│ └── 添加必要的注释
│
└── 3. 保持单一职责
├── Controller:接收请求、转换数据、返回响应
├── Service:业务逻辑、数据库操作
└── DTO:数据校验、类型定义9.3 性能优化建议
code
性能优化建议:
│
├── 1. 避免过度嵌套
│ ├── 嵌套层级不超过 3 层
│ └── 复杂嵌套考虑分步处理
│
├── 2. 合理使用批量操作
│ ├── createMany 代替循环 create
│ └── 使用 skipDuplicates 避免冲突
│
├── 3. 减少数据传输
│ ├── 前端传简化格式
│ └── 后端转换后存储
│
└── 4. 校验优化
├── 使用 skipMissingProperties
└── 合理设置校验分组十、命令速查表
10.1 嵌套校验装饰器速查
| 装饰器 | 说明 | 示例 |
|---|---|---|
@IsInt({ each: true }) | 校验数组元素为整数 | tagIds: number[] |
@IsString({ each: true }) | 校验数组元素为字符串 | tags: string[] |
@ValidateNested() | 启用嵌套对象校验 | profile: ProfileDto |
@ValidateNested({ each: true }) | 启用嵌套数组校验 | items: OrderItemDto[] |
@Type(() => Class) | 指定嵌套类型 | @Type(() => ProfileDto) |
10.2 常用转换代码速查
typescript
// 数组转换为嵌套创建格式
const tags = {
create: dto.tags.map(tagId => ({ tagId }))
};
// 解构赋值分离字段
const { tags, ...courseData } = dto;
// 条件判断处理
if (dto.tags && dto.tags.length > 0) {
// 处理关联数据
}十一、学习要点总结
11.1 核心概念总结
code
NestJS 嵌套数据校验核心要点:
│
├── DTO vs Interface
│ ├── DTO:Controller 层,运行时校验
│ ├── Interface:Service 层,编译时检查
│ └── 分离关注点,各司其职
│
├── 简单类型数组校验
│ ├── 装饰器:@IsInt({}, { each: true })
│ ├── 注意:第一个参数是 IsIntOptions
│ └── 示例:tags: number[]
│
├── 复杂对象嵌套校验
│ ├── 装饰器:@ValidateNested() + @Type()
│ ├── 必须配合 @Type 使用
│ └── 支持:单个对象、对象数组
│
├── 数据转换
│ ├── Controller 层负责
│ ├── 简化前端传参
│ └── 转换为 Service 所需格式
│
└── 最佳实践
├── 分离关注点
├── 简化前端传参
├── 合理使用嵌套校验
└── 明确校验时机11.2 学习路径规划
code
学习路径规划:
│
├── 第一阶段:理解概念(1 天)
│ ├── 理解 DTO 和 Interface 的区别
│ ├── 理解嵌套校验的原理
│ └── 理解数据转换的时机
│
├── 第二阶段:实践操作(2-3 天)
│ ├── 实现简单类型数组校验
│ ├── 实现复杂对象嵌套校验
│ └── 实现数据转换逻辑
│
└── 第三阶段:深入应用(持续)
├── 优化数据转换逻辑
├── 处理复杂嵌套场景
└── 提高代码质量11.3 重要提示
重要提示:嵌套数据校验是 NestJS 数据校验的重要部分,区分 DTO 和 Interface 的使用场景,合理选择校验装饰器。简单类型数组使用
{ each: true },复杂嵌套对象使用@ValidateNested() + @Type()。Controller 层负责数据转换,简化前端传参格式,保持 Service 层纯粹!