Property Decorators > Powerless Yet Essential in NestJS

single

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ạiChữ 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 CreateCommentDto cho việc validate ở HTTP layer (ValidationPipe), hoặc validate thủ công ở nơi khác (gọi validate(dto) trực tiếp) mà không cần sửa gì trong DTO.
  • 1 engine dùng chung cho mọi DTO: ValidationPipe không cần biết trước CreateCommentDto có 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, ValidationPipe tự độ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 đó.

thongvmdev_M9VMOt
WRITTEN BY

thongvmdev

Share and grow together