class-transformer の @Transform 継承で変換が2回実行される問題
はじめに
NestJS で class-transformer の @Transform を使ったバリデーション・変換処理を書いていると、DTO の継承絡みで「なぜか変換が2回かかっている」という状況に遭遇することがあります。
冪等な変換(trim や toUpperCase)だと結果が変わらず気づきにくいですが、税計算のような 非冪等な変換では予期しない値が出力されてしまいます。
問題の再現
以下のような親 DTO を用意します。
class BaseUserDto {
@Transform(({ value }) => String(value).trim().toUpperCase())
name!: string;
}
コードサンプルはこちらに置いておきます。
パターン A:継承のみ(正常)
class CreateUserDto extends BaseUserDto {
// @Transform を追加しない
}
const a = plainToInstance(CreateUserDto, { name: " alice " });
// => "ALICE"(1回だけ実行)
子クラスで @Transform を再定義しなければ、親の変換が1回だけ実行され正常に動きます。
パターン B:継承 + 同じ @Transform を再定義(冪等、気づきにくい)
class AdminUserDto extends BaseUserDto {
@Transform(({ value }) => String(value).trim().toUpperCase()) // 親と同じ変換を再定義
name!: string;
}
const b = plainToInstance(AdminUserDto, { name: " alice " });
// => "ALICE"(2回実行されているが冪等のため値は変わらない)
値の見た目は同じですが、内部では変換が 2回走っています。toUpperCase のような冪等な処理なら問題になりませんが、次のパターンで実害が出ます。
パターン C:非冪等な変換で実害が出るケース
class PriceBaseDto {
@Transform(({ value }) => Math.round(Number(value) * 1.1))
price!: number;
}
class PriceChildDto extends PriceBaseDto {
@Transform(({ value }) => Math.round(Number(value) * 1.1)) // 同じ変換を子で再定義
price!: number;
}
const basePrice = plainToInstance(PriceBaseDto, { price: 1000 });
// => 1100(期待どおり: 1000 * 1.1)
const childPrice = plainToInstance(PriceChildDto, { price: 1000 });
// => 1210(バグ: 1000 * 1.1 * 1.1)
消費税10%を加算するだけのシンプルな変換が、子クラスでは 1.1^2 = 1.21 倍になってしまいます。
なぜ2回実行されるのか
class-transformer の plainToInstance は、メタデータを収集する際に プロトタイプチェーン を辿ります。
PriceChildDto で @Transform を定義すると、そのメタデータは「PriceChildDto のプロトタイプ」と「PriceBaseDto のプロトタイプ」の両方に登録されます。plainToInstance はこれを両方収集するため、結果として変換が2回適用されます。
対処法
子クラスで @Transform を再定義しない
最もシンプルな対処法です。
class PriceChildDto extends PriceBaseDto {
// @Transform を書かない → 親の変換が1回だけ実行される
}
変換を冪等にする
変換ロジックを冪等に設計しておくと、2回実行されても結果が変わりません(ただし根本解決ではない)。
子クラスで @Transform をオーバーライドしたい場合は excludePrefixes などで親を無効化する
完全にオーバーライドしたい場合は、class-transformer のオプションや @Expose / @Exclude を組み合わせて親のメタデータが拾われないよう制御する方法もあります。ただし設定が複雑になるため、できる限り「子クラスでは再定義しない」方針を採るのが安全と思われます。
まとめ
| パターン | 動作 | 注意点 |
|---|---|---|
| 継承のみ | 親の @Transform が1回実行 | 問題なし |
| 継承 + 再定義(冪等) | 2回実行されるが値は変わらない | 気づきにくい |
| 継承 + 再定義(非冪等) | 2回実行され値が変わる | バグの温床 |
DTOを継承して同じプロパティに @Transform を書き直したくなったときは、本当に再定義が必要かどうかを一度確認してみてください。
おわりに
冪等な変換だと見落としやすく、税計算やカウンター系の変換で初めて気づくことが多いパターンです。
class-transformer のプロトタイプチェーン収集の挙動を知っておくと、同種の問題を早めに察知できます。