首页 / NestJS 入门教程 / 数据验证

NestJS 入门教程

数据验证

本教程共 47 篇 · 第 25 篇 · 更新于 2026-08-09 · 约 11 分钟阅读

NestJS数据验证ValidationPipeclass-validatorDTOPipe类型转换

本节目标:掌握 NestJS 中的数据验证机制,学会用 ValidationPipe + class-validator 自动校验请求数据,用内置 Pipe 做类型转换,写出健壮的接口。

永远不要相信客户端传来的数据。

用户可能输错邮箱格式,可能传一个字符串到你期望是数字的地方,甚至可能故意塞一堆多余字段。如果你的接口不做验证,轻则数据脏乱,重则被注入攻击。

NestJS 的验证机制很优雅——写好 DTO 类,贴上验证装饰器,框架自动帮你校验。不合规的数据直接被拦下来,返回 400 错误。

安装依赖

NestJS 的数据验证依赖两个库:

  • class-validator:提供验证装饰器
  • class-transformer:提供对象转换能力
npm install class-validator class-transformer

第一个 DTO

DTO(Data Transfer Object)就是数据传输对象。它定义了客户端应该传什么格式的数据。

// dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, IsString, MinLength, MaxLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail({}, { message: '邮箱格式不正确' })
  @IsNotEmpty({ message: '邮箱不能为空' })
  email: string;

  @IsString()
  @MinLength(6, { message: '密码至少 6 个字符' })
  @MaxLength(32, { message: '密码最多 32 个字符' })
  password: string;

  @IsString()
  @IsNotEmpty({ message: '用户名不能为空' })
  name: string;
}

每个装饰器就是一条验证规则。@IsEmail() 检查是不是合法邮箱,@MinLength(6) 检查最小长度。一个字段可以贴多个装饰器,所有规则都必须通过才算验证成功。

启用 ValidationPipe

光定义 DTO 还不够,你得告诉 NestJS 去验证。方法是使用 ValidationPipe

全局启用(推荐)

// main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // 所有接口都自动验证
  app.useGlobalPipes(new ValidationPipe());
  
  await app.listen(3000);
}
bootstrap();

在控制器方法上启用

如果只想验证某个接口:

@Post()
@UsePipes(new ValidationPipe())
create(@Body() createUserDto: CreateUserDto) {
  return this.userService.create(createUserDto);
}
Tip

建议全局启用。每个接口单独绑太麻烦,而且容易漏掉。

验证失败会怎样?

当客户端传来的数据不符合 DTO 中定义的规则时,NestJS 自动返回 400 响应:

{
  "statusCode": 400,
  "message": [
    "邮箱格式不正确",
    "密码至少 6 个字符"
  ],
  "error": "Bad Request"
}

不需要你写任何错误处理逻辑,框架全包了。

常用验证装饰器

class-validator 提供了大量装饰器,这里列出最常用的:

字符串验证

@IsString()          // 必须是字符串
@IsEmail()           // 必须是邮箱格式
@IsUrl()             // 必须是 URL
@IsUUID()            // 必须是 UUID
@MinLength(3)        // 最小长度
@MaxLength(100)      // 最大长度
@Matches(/^[a-zA-Z]+$/)  // 正则匹配

数字验证

@IsNumber()          // 必须是数字
@IsInt()             // 必须是整数
@Min(0)              // 最小值
@Max(999)            // 最大值
@IsPositive()        // 必须是正数

通用验证

@IsNotEmpty()        // 不能为空
@IsOptional()        // 可选字段(如果不存在则跳过验证)
@IsBoolean()         // 必须是布尔值
@IsArray()           // 必须是数组
@IsEnum(['a', 'b'])  // 必须是枚举值之一
@IsDateString()      // 必须是日期字符串

一个完整的 DTO 示例

import {
  IsString, IsEmail, IsNotEmpty, IsOptional,
  MinLength, MaxLength, IsInt, Min, Max,
  IsEnum, IsArray, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';

export class CreatePostDto {
  @IsString()
  @IsNotEmpty({ message: '标题不能为空' })
  @MaxLength(200, { message: '标题不能超过 200 字' })
  title: string;

  @IsString()
  @MinLength(10, { message: '正文至少 10 个字符' })
  content: string;

  @IsEnum(['draft', 'published', 'archived'], {
    message: '状态必须是 draft、published 或 archived',
  })
  status: string;

  @IsArray()
  @IsString({ each: true })  // 数组中每个元素都必须是字符串
  @IsOptional()
  tags?: string[];

  @IsInt()
  @Min(1)
  @Max(100)
  @IsOptional()
  sortOrder?: number;
}
Note

@IsOptional() 表示这个字段可以不传。如果不加这个装饰器,字段就是必填的。注意:@IsOptional() 只检查”字段不存在”的情况。如果传了 null 或空字符串,它还是会触发其他验证规则。

验证嵌套对象

如果 DTO 里包含嵌套对象,需要加 @ValidateNested() 装饰器,并配合 @Type() 使用:

import { Type } from 'class-transformer';

export class AddressDto {
  @IsString()
  @IsNotEmpty()
  city: string;

  @IsString()
  @IsNotEmpty()
  street: string;
}

export class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()   // 告诉验证器:这是个嵌套对象,要去验证它
  @Type(() => AddressDto)  // 告诉转换器:用 AddressDto 来实例化
  address: AddressDto;
}
Warning

不加 @Type(() => AddressDto) 的话,address 只是一个普通对象,不是 AddressDto 的实例,验证规则不会生效。这是新手常踩的坑。

自动类型转换

HTTP 请求传来的数据都是字符串。URL 参数 ?id=123 里的 123 是字符串 "123",不是数字 123

ValidationPipe 可以自动做类型转换。开启 transform 选项:

app.useGlobalPipes(new ValidationPipe({
  transform: true,
}));

开启后,如果你的控制器方法声明了 id: number,ValidationPipe 会自动把字符串 "123" 转成数字 123

@Get(':id')
findOne(@Param('id') id: number) {
  console.log(typeof id);  // 'number',自动转换了
}
Tip

transform: true 还会把请求体自动转成 DTO 类的实例。这意味着 DTO 上的方法(如果有的话)也能正常使用。

内置 Pipe

除了 ValidationPipe,NestJS 还提供了几个内置 Pipe 做简单的类型转换:

ParseIntPipe

把字符串转成整数。转换失败返回 400。

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  // id 一定是 number 类型
}

ParseBoolPipe

把字符串转成布尔值。"true"true"false"false

@Get()
findAll(@Query('active', ParseBoolPipe) active: boolean) {
  // active 是 boolean
}

ParseArrayPipe

验证和解析数组。适合批量操作或逗号分隔的查询参数:

@Post()
createBulk(
  @Body(new ParseArrayPipe({ items: CreateUserDto }))
  users: CreateUserDto[],
) {
  return this.userService.createBulk(users);
}

@Get()
findByIds(
  @Query('ids', new ParseArrayPipe({ items: Number, separator: ',' }))
  ids: number[],
) {
  // GET /users?ids=1,2,3 -> ids = [1, 2, 3]
  return this.userService.findByIds(ids);
}

ParseUUIDPipe

验证 UUID 格式:

@Get(':id')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
  // id 一定是 UUID 格式
}

白名单:过滤多余字段

客户端可能传一些你不需要甚至不该接收的字段。比如你的 DTO 只有 emailpassword,但客户端多传了一个 role: 'admin'

whitelist: true 会自动去掉 DTO 中没有声明的字段:

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
}));

更进一步,forbidNonWhitelisted: true 不仅去掉多余字段,还直接报错:

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,
}));

这时候如果客户端传了多余字段,会收到 400 错误。

Tip

生产环境建议开启 whitelist: true。这是一个很好的安全习惯——只接收你需要的数据,多余的直接丢掉。

映射类型:减少重复代码

做 CRUD 的时候,Create DTO 和 Update DTO 往往很像。区别是:创建时所有字段必填,更新时所有字段可选。

不用把每个字段重新写一遍。NestJS 提供了映射类型工具函数:

PartialType:所有字段变可选

import { PartialType } from '@nestjs/mapped-types';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

UpdateUserDto 继承了 CreateUserDto 的所有字段和验证规则,但每个字段都变成了可选的。

PickType:只取部分字段

import { PickType } from '@nestjs/mapped-types';

export class UpdatePasswordDto extends PickType(CreateUserDto, ['password'] as const) {}

只取 password 字段。

OmitType:排除部分字段

import { OmitType } from '@nestjs/mapped-types';

export class UpdateUserDto extends OmitType(CreateUserDto, ['email'] as const) {}

取除了 email 以外的所有字段。

IntersectionType:合并两个 DTO

import { IntersectionType } from '@nestjs/mapped-types';

export class CreateUserWithProfileDto extends IntersectionType(
  CreateUserDto,
  CreateProfileDto,
) {}

把两个 DTO 的字段合并到一起。

Note

这些映射类型可以组合使用。比如”排除 email 字段,然后全部变可选”:

export class UpdateUserDto extends PartialType(
  OmitType(CreateUserDto, ['email'] as const),
) {}

自定义验证装饰器

class-validator 提供的装饰器不够用?你可以自定义验证规则。

方式一:用 @Validate 和 ValidatorConstraint

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  Validate,
} from 'class-validator';

// 定义验证逻辑
@ValidatorConstraint({ async: true })
export class IsEmailAlreadyExist implements ValidatorConstraintInterface {
  constructor(private userService: UserService) {}

  async validate(email: string): Promise<boolean> {
    const user = await this.userService.findByEmail(email);
    return !user;  // 如果用户不存在,验证通过
  }

  defaultMessage(): string {
    return '该邮箱已被注册';
  }
}

// 在 DTO 中使用
export class CreateUserDto {
  @Validate(IsEmailAlreadyExist)
  email: string;
}
Warning

自定义验证器如果用了依赖注入(比如上面的 UserService),需要通过 useContainer 设置容器,否则注入不生效。

方式二:自定义装饰器(更简洁)

import { registerDecorator, ValidationOptions } from 'class-validator';

export function IsStrongPassword(validationOptions?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: 'isStrongPassword',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: string) {
          // 至少包含一个大写字母、一个小写字母和一个数字
          return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(value);
        },
        defaultMessage() {
          return '密码必须至少 8 位,包含大小写字母和数字';
        },
      },
    });
  };
}

// 使用
export class CreateUserDto {
  @IsStrongPassword({ message: '密码强度不够' })
  password: string;
}

自定义错误格式

默认的验证错误格式可能不符合你的 API 规范。可以自定义 exceptionFactory

app.useGlobalPipes(new ValidationPipe({
  exceptionFactory: (errors) => {
    const formattedErrors = errors.reduce((acc, error) => {
      acc[error.property] = Object.values(error.constraints || {});
      return acc;
    }, {} as Record<string, string[]>);

    return {
      statusCode: 400,
      code: 'VALIDATION_ERROR',
      errors: formattedErrors,
    };
  },
}));

这样返回的错误信息更结构化:

{
  "statusCode": 400,
  "code": "VALIDATION_ERROR",
  "errors": {
    "email": ["邮箱格式不正确"],
    "password": ["密码至少 6 个字符"]
  }
}

生产环境的注意事项

生产环境中,详细的验证错误信息可能暴露内部实现细节。可以关闭错误消息:

app.useGlobalPipes(new ValidationPipe({
  disableErrorMessages: true,
}));

这样验证失败时只返回 400 状态码,不返回具体原因。

踩坑经验

1. DTO 必须用 class,不能用 interface

TypeScript 的 interface 在编译后会消失。运行时 NestJS 拿不到任何信息,验证自然不生效。必须用 class 定义 DTO。

// 错误:interface 编译后消失,验证不生效
interface CreateUserDto {
  email: string;
}

// 正确:class 在运行时存在
class CreateUserDto {
  @IsEmail()
  email: string;
}

2. 导入 DTO 时不要用 type-only import

// 错误:type-only import 在运行时会被擦除
import type { CreateUserDto } from './dto/create-user.dto';

// 正确
import { CreateUserDto } from './dto/create-user.dto';

3. 嵌套对象必须加 @Type()

这是新手最常犯的错误。不加 @Type(() => ChildDto),嵌套对象不会被正确验证。

4. @IsOptional() 的位置

@IsOptional() 要放在其他验证装饰器前面:

// 正确
@IsOptional()
@IsString()
name?: string;

// 可能有问题
@IsString()
@IsOptional()
name?: string;

5. transform 和 whitelist 可以一起用

new ValidationPipe({
  transform: true,     // 自动类型转换
  whitelist: true,     // 过滤多余字段
  forbidNonWhitelisted: true,  // 多余字段直接报错
})

这三个选项配合使用,是生产环境的最佳实践。

小结

关键知识点回顾:

  • DTO 必须用 class 定义,interface 编译后消失,验证不生效
  • ValidationPipe 配合 class-validator 装饰器自动校验请求数据
  • @Type(() => ChildDto) 处理嵌套对象验证
  • whitelist: true 过滤多余字段,transform: true 自动类型转换
  • 自定义验证装饰器用 createParamDecoratorregisterDecorator

下一章我们聊聊数据库集成,从 TypeORM 开始。