Validation DTO trong NestJS: vì sao role "admin" và địa chỉ sai kiểu vẫn lọt Cơ bản
Thời gian đọc ước tính: 10 phút
Vấn đề
Phần tiêu đề “Vấn đề”API PATCH /users/me cho user sửa displayName, locale và address. Handler nhận @Body() dto: UpdateMeDto rồi lưu xuống database. Code biên dịch không lỗi, test happy path đều xanh.
Một client gửi thêm "role": "admin" và address.street = 42. Request trả 200, role đi tới repository: đây là mass assignment, client ghi được field không được phép sửa (xem Secure coding). Địa chỉ sai kiểu cũng được lưu.
Câu hỏi: vì sao kiểu UpdateMeDto không chặn được gì, và cấu hình ValidationPipe thế nào để cả field lạ lẫn object lồng nhau đều bị kiểm tra?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Mọi API nhận body | ValidationPipe global với whitelist: true |
Mọi field hợp lệ đều có ít nhất một decorator |
| Muốn client biết đã gửi field lạ | Thêm forbidNonWhitelisted: true |
Client chấp nhận 400 thay vì bị bỏ qua thầm lặng |
| DTO có object hoặc mảng lồng nhau | @ValidateNested() và @Type(() => X) |
Mảng thêm { each: true } |
| Query param cần số | transform: true + @Type(() => Number) hoặc ParseIntPipe |
Tránh enableImplicitConversion cho boolean |
| Format lỗi riêng (Problem Details) | exceptionFactory |
Mặc định 400; site dùng 422 (xem exception filter) |
| e2e test phải giống production | Khai báo pipe bằng APP_PIPE trong module |
Thay cho useGlobalPipes chỉ ở main.ts |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”| Khái niệm | Ý nghĩa |
|---|---|
| DTO | Class mô tả hình dạng input của một endpoint, gắn decorator validation |
plainToInstance |
Hàm của class-transformer: biến plain object từ JSON thành instance của class |
validate |
Hàm của class-validator: đọc decorator trên instance, trả mảng ValidationError |
| Pipe | Lớp NestJS validate và transform tham số ngay trước handler |
Type TypeScript bị xoá khi biên dịch. Lúc runtime, dto: UpdateMeDto chỉ là object do Express parse từ JSON. Thứ còn lại là class UpdateMeDto trong metadata tham số (nhờ emitDecoratorMetadata) và các decorator gắn trên property. Không có decorator thì không có rule nào để kiểm.
ValidationPipe là một pipe, nên chạy sau guard và interceptor (chiều vào), ngay trước handler, như mô tả ở Request lifecycle:
Đang tải diagram…
Hệ quả: guard đọc req.body sẽ thấy dữ liệu chưa qua validation.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Theo source ValidationPipe (@nestjs/common 11.2.6), body có role và address sai kiểu đi qua các bước:
{ role, address }Client gửi role: "admin" và address.street = 42. Body là plain object; kiểu UpdateMeDto chỉ còn trong metadata.
| Bước | Trước | Sau |
|---|---|---|
1. plainToInstance(UpdateMeDto, body) |
Plain object, address cũng plain |
Instance UpdateMeDto; address thành AddressDto chỉ khi có @Type |
2. validate(entity, options) với whitelist |
Còn role |
role bị xoá, hoặc thành lỗi nếu forbidNonWhitelisted |
| 3. Rule từng property, đệ quy vào nested | Chưa kiểm | Mảng ValidationError |
| 4a. Có lỗi | exceptionFactory ném BadRequestException, handler không chạy |
|
| 4b. Không lỗi | transform: true trả instance; false trả plain object |
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”whitelistbỏ property không có decorator validation nào.@IsOptional()cũng tính là decorator.forbidNonWhitelistedchỉ có tác dụng khi bậtwhitelist; lỗi có dạngproperty role should not exist.- Nested:
@Type(() => AddressDto)để class-transformer tạo instance;@ValidateNested()để class-validator đi vào instance đó. Mảng dùng@ValidateNested({ each: true }). - Query và route param luôn là chuỗi.
transform: truechỉ tự đổi kiểu cho tham số đơn có tên, như@Query('active') active: boolean. enableImplicitConversionđổi theo kiểu khai báo của property. Với boolean, chuỗi"false"thànhtrue.- Lỗi mặc định: HTTP 400,
messagelà mảng chuỗi; nested có dạngaddress.zip ..., phần tử mảng làshippingAddresses.1.zip .... PartialTypecho DTO của PATCH nằm trong package riêng@nestjs/mapped-types(repo demo chưa cài). Theo docs, nó giữ decorator validation và đánh dấu mọi field optional.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo examples/src/nestjs-validation-dto/validation-demo.ts (NestJS 11.2.6, class-validator 0.15.1, class-transformer 0.5.1) dựng app cho từng cấu hình, gửi body bằng fetch, in response thật. DTO:
class AddressDto { @IsString() @Length(1, 120) street!: string; @Matches(/^\d{5,6}$/) zip!: string;}class UpdateMeDto { @IsOptional() @IsString() @Length(1, 80) displayName?: string; @IsOptional() @IsIn(['vi', 'en']) locale?: string; @IsOptional() @ValidateNested() @Type(() => AddressDto) address?: AddressDto; @IsOptional() @ArrayMaxSize(3) @ValidateNested({ each: true }) @Type(() => AddressDto) shippingAddresses?: AddressDto[];}Body {"displayName":"An","role":"admin"} qua ba cấu hình:
[1] Không có ValidationPipe -> 200 {"displayName":"An","role":"admin"}[2] new ValidationPipe() -> 200 {"displayName":"An","role":"admin"}[3] whitelist: true -> 200 {"displayName":"An"}[4] + forbidNonWhitelisted: true -> 400 {"message":["property role should not exist"],...}Cấu hình [2] có validate, nhưng role vẫn tới handler vì không ai bảo pipe bỏ field lạ.
Nested: thiếu một trong hai decorator
Phần tiêu đề “Nested: thiếu một trong hai decorator”Body {"displayName":"An","address":{"street":42,"zip":"abc"}}, kết quả thật:
| DTO | Cấu hình | Response |
|---|---|---|
Đủ @ValidateNested + @Type |
whitelist |
400: address.street must be a string, address.zip must match ... |
Thiếu @Type |
không whitelist |
200, address sai kiểu lọt |
Thiếu @Type |
whitelist |
200, address: {}: mất dữ liệu thầm lặng |
Thiếu @Type |
whitelist + forbidNonWhitelisted |
400 khó hiểu: address.property street should not exist |
Thiếu @ValidateNested |
whitelist + forbidNonWhitelisted |
200, address sai kiểu lọt |
Thiếu @Type, address vẫn là plain object: không có rule nào của AddressDto, và whitelist xoá mọi key của nó. Thiếu @ValidateNested, validator không đi vào address.
Mảng nested với each: true báo đúng chỉ số phần tử hỏng:
PATCH /users/me {"shippingAddresses":[{"street":"Lê Lợi","zip":"700000"},{"street":"","zip":"1"}]}-> 400 {"message":["shippingAddresses.1.street must be longer than or equal to 1 characters", "shippingAddresses.1.zip must match /^\\d{5,6}$/ regular expression"],...}Query param và enableImplicitConversion
Phần tiêu đề “Query param và enableImplicitConversion”DTO ListUsersQuery có limit?: number (@IsInt) và active?: boolean (@IsBoolean). Gọi GET /users?limit=20&active=false:
[5] whitelist -> 400 ["...limit must be an integer number","active must be a boolean value"][6] + transform: true -> 400 (giống [5])[7] + transformOptions.enableImplicitConversion: true -> 200 {"limit":20,"active":true}Tham số đơn @Query('active') active: boolean, transform: true -> 200 {"active":false}transform: true không đổi kiểu property trong DTO query. Implicit conversion đổi được, nhưng "false" thành true, giống Boolean("false"). Trong một thử nghiệm riêng, nó còn đổi street: 42 thành "42" nên @IsString() qua.
Cấu hình ValidationPipe global
Hay gặp
app.useGlobalPipes(new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true },}));// role lọt; ?active=false thành trueNên viết
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true,}));// Query: @Type(() => Number) cho số,// @Transform(({ value }) => value === 'true') cho booleanVì sao
Bản sai bỏ qua field lạ và đoán kiểu theo metadata. Bản đúng chặn field lạ, còn mỗi chuyển kiểu được viết rõ trên property.
exceptionFactory và e2e test
Phần tiêu đề “exceptionFactory và e2e test”exceptionFactory nhận ValidationError[] (có children cho nested) và trả exception. Demo [8] đổi sang danh sách field và rule:
-> 400 {"type":"validation","errors":[{"field":"role","rules":["whitelistValidation"]}, {"field":"address.street","rules":["isLength","isString"]},{"field":"address.zip","rules":["matches"]}]}Muốn trả Problem Details thì định dạng lỗi ở đây hoặc ở exception filter, xem Problem Details.
useGlobalPipes trong main.ts không chạy trong Test.createTestingModule. Demo [9] gửi role qua app test: 200. Demo [10] khai báo { provide: APP_PIPE, useValue: new ValidationPipe({...}) } trong module: 400, giống production.
Chạy demo:
npx --prefix examples ts-node examples/src/nestjs-validation-dto/validation-demo.tsTrade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Rule nằm cạnh field, đọc DTO là biết contract của endpoint.
-
whitelistbiến DTO thành allowlist, chặn mass assignment ở biên. -
Lỗi 400 có đường dẫn field (
address.zip), client sửa được ngay.
< slot="cons"> >
-
Thiếu một decorator là mất rule mà không có lỗi biên dịch.
-
plainToInstancevàvalidatetốn CPU với body lớn, mảng dài. -
Chỉ kiểm hình dạng; quyền sửa field và ràng buộc nghiệp vụ vẫn thuộc service.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Đặt
ValidationPipevớiwhitelist: true,forbidNonWhitelisted: truebằngAPP_PIPEđể test và production dùng chung. - Mỗi property hợp lệ có ít nhất một decorator; field optional dùng
@IsOptional(). - Mọi object lồng nhau có cặp
@ValidateNested()+@Type(); mảng thêmeach: truevà@ArrayMaxSize. - Giới hạn độ dài chuỗi và kích thước mảng; body hợp lệ về kiểu vẫn có thể quá lớn.
- Không bật
enableImplicitConversiontoàn cục khi DTO có boolean; chuyển kiểu query bằng@Typehoặc@Transform. - Tách DTO cho user và admin; không dùng entity ORM làm DTO.
- Viết e2e test gửi field lạ và nested sai kiểu, kỳ vọng 400.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Kiểu TypeScript của tham số là validation | Mọi JSON đều lọt vào handler | Gắn decorator và bật ValidationPipe |
new ValidationPipe() đã chặn field lạ |
role: "admin" tới repository |
Bật whitelist |
@ValidateNested() là đủ cho nested |
Nested bị bỏ qua hoặc bị xoá rỗng | Thêm @Type(() => X) |
Pipe global ở main.ts cũng chạy trong e2e test |
Test xanh, production khác | APP_PIPE hoặc gọi useGlobalPipes trong setup test |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao
new ValidationPipe()không option trả 400 choaddresssai kiểu nhưng vẫn đểrolelọt? - DTO thiếu
@Typetrênaddress, bậtwhitelist: handler nhậnaddresslà gì, và vì sao? - Vì sao
?active=falsethànhtruekhi bậtenableImplicitConversion, còn@Query('active') active: booleanthì không? - e2e test dùng
Test.createTestingModuletrả 200 cho body córole. Sửa ở đâu để test phản ánh production?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- Validation — NestJS: option của
ValidationPipe,whitelist,transform, implicit conversion, mapped types. - Pipes — NestJS: pipe global và đăng ký bằng
APP_PIPE. - class-validator README:
@ValidateNested,each, whitelisting, cấu trúcValidationError. - class-transformer README:
plainToInstance,@Typecho nested object và mảng.