Cheatsheet: ObjectId bị trả về random khi dùng plainToInstance

Triệu chứng

Field kiểu ObjectId trong response DTO trả về giá trị khác nhau mỗi lần gọi API, dù data gốc trong DB không đổi. Log cho thấy giá trị trong @Transform là các ObjectId mới, timestamp gần nhau (tạo liên tiếp trong cùng 1 tick).

plaintext
detail.internalInvoiceId   → "6a7a88a78b6a1bd07124a726"        (ổn định, đọc trực tiếp)
value trong @Transform     → ObjectId("6a852ae4e990404b4b96f49f")  (khác mỗi lần!)

Root cause

Field có @Transform nhưng không có @Type() đi kèm.

class-transformer không biết target type, và khi gặp value là instance có constructor riêng (như Types.ObjectId), nó cố “clone” bằng cách gọi new value.constructor() không có argument rồi copy properties sang. Nhưng:

typescript
new Types.ObjectId()  // không có argument → tự sinh ObjectId RANDOM MỚI

→ Kết quả: @Transform nhận một ObjectId hoàn toàn mới, không phải giá trị gốc.

Điều kiện để bug xảy ra

  • ✅ Field nhận trực tiếp ObjectId (hoặc Date, Buffer — các class có constructor side-effect tương tự)
  • ✅ Có @Transform, không có @Type()
  • ✅ plainToInstance không bật enableImplicitConversion: true

Cách fix — chọn 1 trong 3

Fix 1: Bật enableImplicitConversion (fix nhanh, toàn cục)

typescript
plainToInstance(Dto, detail, {
  excludeExtraneousValues: true,
  enableImplicitConversion: true, // bỏ qua nhánh "clone qua constructor"
});

⚠️ Fix ở tầng call site — chỉ nên dùng làm safety net, không thay thế fix tận field.

Fix 2: Thêm @Type(() => String) (đơn giản nhất, khuyến nghị)

typescript
@ApiProperty()
@Expose()
@Type(() => String)
internalInvoiceId: string;

String(objectId) tự động gọi đúng .toHexString() nhờ custom toString() của ObjectId — không cần @Transform nữa. Target type rõ ràng nên class-transformer không cần đoán/clone.

Fix 3: Custom decorator dùng chung (khuyến nghị nếu nhiều DTO bị)

typescript
// decorators/expose-object-id.decorator.ts
import { Expose, Transform } from 'class-transformer';
import { Types } from 'mongoose';
import { applyDecorators } from '@nestjs/common';

export function ExposeObjectId() {
  return applyDecorators(
    Expose(),
    Transform(({ value }) => {
      if (!value) return null;
      return value instanceof Types.ObjectId
        ? value.toHexString()
        : String(value);
    }),
  );
}
typescript
@ApiProperty()
@ExposeObjectId()
internalInvoiceId: string;

Field tự chủ động convert đúng, không phụ thuộc vào enableImplicitConversion ở call site.

Quy tắc chung để tránh lặp lại

Tình huốngViệc cần làm
Field nhận ObjectId/Date/Buffer từ Mongoose, có @TransformLuôn thêm @Type() tương ứng, hoặc dùng decorator gộp sẵn
Không chắc control hết mọi fieldSet enableImplicitConversion: true làm safety net
Muốn tránh toàn bộ vấn đề class-instance quirks.lean() ngay từ query Mongoose, hoặc JSON.parse(JSON.stringify(doc)) trước khi plainToInstance
Nhiều DTO cùng patternGrep toàn project field xxxId: string map từ ObjectId → áp @ExposeObjectId() đồng loạt

Debug nhanh khi nghi ngờ bug tương tự

typescript
console.log('is Document?', detail instanceof Document);
console.log('typeof field', typeof detail.someField, detail.someField?.constructor?.name);
console.log('raw', detail.toObject ? detail.toObject().someField : detail.someField);

Nếu constructor.name là ObjectId/Date/… và field không có @Type() → nghi ngờ ngay bug này.

Nguyen Pham

Xin chào. Mình thích lập trình và công nghệ. Đây là blog mình chia sẻ lại các kiến thức về lập trình, công nghệ.