{T}

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.ts

3.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.ts

5.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 层纯粹!


十二、参考资料