Mở đầu
Trong họ hàng decorator của TypeScript, property decorator là loại "yếu" nhất — nó không có quyền sửa giá trị, không có quyền chặn hành vi, thậm chí lúc nó chạy, property mà nó đang decorate còn chưa hề có giá trị gì. Vậy mà đây lại là loại decorator bạn chạm vào nhiều nhất khi làm việc với NestJS, thông qua class-validator:
export class CreateCommentDto { @IsString() @IsNotEmpty() comment_text: string; }
Bài viết này đi sâu vào việc: property decorator thực sự làm gì, tại sao nó "bất lực", và bằng cách nào 2 dòng @IsString() / @IsNotEmpty() tưởng chừng vô hại này lại biến thành 1 lớp validate hoàn chỉnh — tất cả minh họa qua 1 flow thực tế: @Body() nhận CreateCommentDto trong 1 NestJS controller.
1. Property decorator là gì — nhìn vào chữ ký hàm
function IsString() { return function (target: any, propertyKey: string) { // Chỉ 2 tham số — không descriptor, không index }; }
So sánh nhanh với các loại decorator khác:
| Loại | Chữ ký | Có thể sửa hành vi/giá trị? |
|---|---|---|
| Method decorator | (target, key, descriptor) | Có — sửa descriptor.value |
| Parameter decorator | (target, key, index) | Không — chỉ ghi vị trí |
| Property decorator | (target, key) | Không — chỉ ghi tên property |
Không có descriptor, nghĩa là property decorator không có cách nào can thiệp vào việc đọc/ghi giá trị của property đó. Nó chỉ biết đúng 1 thứ: tên của property (propertyKey).
2. Vì sao nó "bất lực" — vấn đề nằm ở thời điểm chạy
class CreateCommentDto { @IsString() @IsNotEmpty() comment_text: string; // ← chỉ là khai báo, CHƯA có giá trị }
Decorator chạy ngay khi class được định nghĩa — tức là lúc file này được import, trước khi bất kỳ ai gọi new CreateCommentDto(). Tại thời điểm đó, không hề tồn tại 1 giá trị comment_text nào để mà kiểm tra. Class chỉ là 1 "khuôn mẫu", chưa có instance, chưa có dữ liệu thật.
Đây chính là lý do property decorator không thể tự mình validate ngay lúc nó chạy — nó buộc phải chọn con đường duy nhất còn lại: ghi lại 1 cái nhãn, chờ ai đó xử lý sau.
3. Full flow — từ decorator tới lỗi 400 thật sự
Đây là toàn bộ hành trình của @IsString() / @IsNotEmpty(), đi qua đúng 3 giai đoạn thời gian tách biệt: Module Load → Bootstrap → Request.
MODULE LOAD: 1. @IsString(), @IsNotEmpty() trên CreateCommentDto.comment_text → ghi VALIDATORS_METADATA lên CreateCommentDto.prototype 2. @Body() trên tham số body của create() → ghi: { index: 0, source: 'body' } lên CommentsController.prototype.create → TS compiler TỰ ĐỘNG thêm: design:paramtypes = [CreateCommentDto] (nhờ emitDecoratorMetadata: true) BOOTSTRAP: 3. RouterExplorer đọc metadata của create() → thấy tham số index 0: source='body', metatype=CreateCommentDto REQUEST THẬT — POST body { comment_text: "" }: 4. Handler (do Nest sinh ra) gọi: globalValidationPipe.transform(req.body, { metatype: CreateCommentDto }) ↑ LẤY TỪ design:paramtypes ở bước 2 5. ValidationPipe: - new CreateCommentDto() rồi Object.assign(instance, req.body) - Tra VALIDATORS_METADATA của CreateCommentDto (ghi từ bước 1) - So sánh instance.comment_text với rule → fail isNotEmpty → throw 400
Hãy mổ xẻ từng bước.
Bước 1 — Property decorator chỉ ghi 1 dòng metadata
// Compile ra JS, __decorate gọi: IsString()(CreateCommentDto.prototype, "comment_text"); IsNotEmpty()(CreateCommentDto.prototype, "comment_text");
Bên trong class-validator, mỗi lệnh gọi này chỉ làm 1 việc — thêm 1 rule vào 1 kho lưu trữ metadata gắn với CreateCommentDto:
// Nguyên lý đơn giản hóa Reflect.defineMetadata('__validators__', [ { property: 'comment_text', type: 'isString' }, { property: 'comment_text', type: 'isNotEmpty' }, ], CreateCommentDto.prototype);
Chú ý: metadata được gắn lên prototype, không phải instance. Điều này quan trọng — nó nghĩa là rule chỉ cần khai báo 1 lần duy nhất trên class, mọi instance của CreateCommentDto sau này (dù được tạo ra bao nhiêu lần) đều dùng chung 1 bộ rule đó.
Bước 2 — Mảnh ghép còn thiếu: design:paramtypes
Property decorator ghi được rule, nhưng còn 1 vấn đề: làm sao mà lúc runtime, hệ thống biết được body: CreateCommentDto trong create() là loại DTO nào để mà tra đúng rule? Type CreateCommentDto trong TypeScript vốn biến mất hoàn toàn sau khi compile — JavaScript không có khái niệm type.
Đây là lúc emitDecoratorMetadata: true trong tsconfig.json phát huy tác dụng. Khi bật cờ này, TypeScript compiler tự động chèn thêm 1 dòng mỗi khi thấy tham số có decorator:
__metadata("design:paramtypes", [CreateCommentDto])
Dòng này ghi lại: "tham số của method create có type thật là [CreateCommentDto]" — đây chính là cách Nest "nhìn xuyên" qua ranh giới compile-time/runtime để biết được kiểu TypeScript, dù bản thân JavaScript không hề lưu giữ thông tin type.
→ Ghép với @Body() (chỉ nói "lấy từ req.body, ở index 0"), giờ hệ thống đã có đủ 2 mảnh: lấy từ đâu + là type gì.
Bước 3 — RouterExplorer đọc lại, nhưng KHÔNG tự validate
Lúc app bootstrap, RouterExplorer quét CommentsController, đọc metadata của create(), thấy: "tham số index 0 lấy từ body, type là CreateCommentDto". Nó chỉ ghi nhớ thông tin này vào 1 closure handler sẽ dùng sau — bản thân RouterExplorer không đọc VALIDATORS_METADATA của CreateCommentDto, đó không phải việc của nó.
Bước 4 & 5 — ValidationPipe: "bên thứ 3" thực sự làm việc
Khi request thật đến với comment_text: "", closure handler gọi:
globalValidationPipe.transform(req.body, { metatype: CreateCommentDto });
Và đây là nơi mọi thứ được nối lại thành 1:
transform(rawValue, { metatype }) { // Tạo instance THẬT — bây giờ mới có giá trị const instance = Object.assign(new metatype(), rawValue); // Tra lại rule đã ghi từ Bước 1, gắn với property "comment_text" const rules = Reflect.getMetadata('__validators__', metatype.prototype); // So sánh giá trị thật với rule for (const rule of rules) { const value = instance[rule.property]; // instance.comment_text = "" if (rule.type === 'isNotEmpty' && value === '') { throw new Error(`${rule.property} should not be empty`); } } return instance; }
Đến tận bước này — cách xa hàng trăm dòng code và 3 giai đoạn thời gian so với lúc @IsNotEmpty() chạy — giá trị comment_text mới thực sự tồn tại và được đối chiếu với rule đã ghi từ module load.
4. Sơ đồ tách biệt — chìa khóa để hiểu property decorator
MODULE LOAD (1 lần, khi import file) REQUEST THẬT (mỗi lần gọi API) ────────────────────────────────── ────────────────────────────── @IsString() → ghi nhãn "phải là string" @IsNotEmpty() → ghi nhãn "không được rỗng" ValidationPipe.transform() → ├─ tạo instance CÓ GIÁ TRỊ THẬT (comment_text CHƯA có giá trị nào) ├─ tra lại nhãn đã ghi ├─ so sánh giá trị với nhãn └─ throw 400 hoặc trả về instance sạch
Property decorator sống trọn vẹn ở cột bên trái — nó hoàn thành nhiệm vụ và "biến mất" ngay khi module được load, rất lâu trước khi có request nào xảy ra. Toàn bộ "sức mạnh" mà bạn thấy (tự động trả lỗi 400 khi thiếu field) không nằm trong property decorator, mà nằm ở ValidationPipe — một class hoàn toàn tách biệt, chỉ được viết ra 1 lần và dùng chung cho mọi DTO trong toàn bộ ứng dụng.
5. Vì sao thiết kế "bất lực rồi giao lại cho bên khác" lại là điểm mạnh
Nghe qua có vẻ property decorator là loại decorator "kém cỏi" — không tự làm được gì. Nhưng chính sự "bất lực" này lại là điều làm nên sức mạnh của cả hệ sinh thái class-validator + NestJS:
- Tách biệt khai báo và thực thi: DTO chỉ cần khai báo "rule là gì", không cần biết "ai sẽ chạy rule đó, chạy khi nào". Bạn có thể dùng cùng 1
CreateCommentDtocho việc validate ở HTTP layer (ValidationPipe), hoặc validate thủ công ở nơi khác (gọivalidate(dto)trực tiếp) mà không cần sửa gì trong DTO. - 1 engine dùng chung cho mọi DTO:
ValidationPipekhông cần biết trướcCreateCommentDtocó bao nhiêu property, rule gì — nó chỉ cần đọc metadata lúc runtime. Thêm 1 DTO mới, thêm rule mới,ValidationPipetự động hoạt động đúng mà không cần sửa 1 dòng code nào trong chính nó. - Đúng tinh thần Aspect-Oriented Programming: logic nghiệp vụ (
comment_text: string) và cross-cutting concern (validate rule) tách bạch hoàn toàn — bạn nhìn vào DTO là biết ngay rule, không cần lục tìm code validate nằm rải rác ở đâu.
Tổng kết
Property decorator là loại decorator "yếu" nhất trong 4 loại của TypeScript — không descriptor, không index, và quan trọng nhất: chạy ở thời điểm chưa hề có giá trị nào tồn tại. Nó chỉ làm được 1 việc: ghi nhãn lên prototype của class, gắn với tên property.
Nhưng chính nhờ giới hạn này, property decorator buộc phải tồn tại theo đúng mô hình "khai báo ở một nơi, thực thi ở một nơi khác, vào một thời điểm khác" — và đó chính xác là mô hình đứng sau class-validator + ValidationPipe của NestJS: @IsString()/@IsNotEmpty() chỉ dán nhãn lúc module load, còn việc validate thật sự chỉ xảy ra khi có 1 request HTTP thật, được thực hiện bởi ValidationPipe — một class hoàn toàn tách biệt, đọc lại đúng những nhãn đã được dán từ rất lâu trước đó.
