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).
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:
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)
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ị)
@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ị)
// 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);
}),
);
}
@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ống | Việc cần làm |
|---|---|
| Field nhận ObjectId/Date/Buffer từ Mongoose, có @Transform | Luô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 field | Set 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 pattern | Grep toàn project field xxxId: string map từ ObjectId → áp @ExposeObjectId() đồng loạt |
Debug nhanh khi nghi ngờ bug tương tự
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.

