class-transformer の @Transform 継承で変換が2回実行される問題

class-transformer の @Transform 継承で変換が2回実行される問題


はじめに

NestJS で class-transformer@Transform を使ったバリデーション・変換処理を書いていると、DTO の継承絡みで「なぜか変換が2回かかっている」という状況に遭遇することがあります。

冪等な変換(trimtoUpperCase)だと結果が変わらず気づきにくいですが、税計算のような 非冪等な変換では予期しない値が出力されてしまいます。

問題の再現

以下のような親 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-transformerplainToInstance は、メタデータを収集する際に プロトタイプチェーン を辿ります。

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 のプロトタイプチェーン収集の挙動を知っておくと、同種の問題を早めに察知できます。