DTO(Data Transfer Object)は、層やプロセスの間でデータを運ぶためだけのオブジェクトです。この記事では、まず用語の意味とEntity・DAOとの違いを整理し、後半でNestJS 12のDTO定義とValidationPipeの設定を扱います。whitelistとforbidNonWhitelistedの組み合わせは、NestJS 12.0.3で実際にリクエストを送って確かめた結果を載せています。
まとめ:DTOの意味とNestJSで押さえる設定
- DTOは、データの受け渡しに使うオブジェクトです。Martin Fowlerの定義では、プロセス間の呼び出し回数を減らすためにデータをまとめて運ぶことが目的です。
- Entityは永続化や業務ルールを担い、DTOは外部との入出力の形を決めます。同じクラスで兼ね、出力項目の選別をしないと、パスワードハッシュなどの内部項目がAPIの応答に漏れるおそれがあります。
- NestJSのDTOはinterfaceではなくclassで書きます。interfaceはコンパイル後に消え、ValidationPipeが型情報を参照できないためです。
- グローバルのValidationPipeは
whitelist: trueとforbidNonWhitelisted: trueを組み合わせて指定します。forbidNonWhitelistedだけでは、未定義のプロパティがそのまま通ります。 - 更新用DTOは
PartialTypeとOmitTypeで作成用DTOから派生させます。Swaggerを使うプロジェクトでは@nestjs/swaggerからimportします。
以下では、用語の定義、NestJSでの書き方、オプションごとの挙動の順に説明します。
DTOの意味と本来の目的
Fowlerによる定義とリモート呼び出しの削減
DTOという用語は、Martin Fowlerの著書『Patterns of Enterprise Application Architecture』(2002年)で広まりました。同書のパターンカタログは、DTOを「An object that carries data between processes in order to reduce the number of method calls.」と定義しています。リモートのインターフェースは1回の呼び出しが高くつくので、呼び出しの回数を減らし、1回で多くのデータを運ぶ必要がある、という説明です。
たとえば、顧客名・住所・電話番号をリモートで1項目ずつ取得すると、通信が3回発生します。これを1つのDTOにまとめれば、通信は1回で済みます。DTO自体は業務ロジックを持たず、シリアライズできる値の入れ物にとどめます。
WebアプリのDTOによるAPI入出力の定義
現在のWebアプリでDTOと呼ぶものは、主にHTTPのリクエストボディやレスポンスの形を表すクラスです。コントローラーは受け取ったJSONをDTOとして検証し、サービス層へ渡します。レスポンスでも、Entityをそのまま返さず、公開してよい項目だけを持つDTOに詰め替えます。
なお、Fowlerは2004年10月21日のブログ記事「LocalDTO」で、同じプロセス内でDTOを使うことに否定的な見解を示しています。粒度の粗いAPIは扱いにくく、ドメイン層からDTOへの詰め替えも手間になるためです。ただし、画面のモデルとドメインモデルの形が大きく違う場合は例外としています。同記事の追記では、マルチスレッドアプリの隔離された領域間で、DTOをメッセージとして受け渡す用途も挙げています。WebのAPIはプロセスの境界にあたるので、DTOを置くのはこの定義に沿った使い方です。一方、同じアプリの内部で層ごとにDTOを増やす設計は、この指摘に当てはまります。
DTOとEntity・DAO・値オブジェクトの違い
DTOと混同されやすい用語を、役割で比べます。
| 用語 | 役割 | ロジック | 同一性 | NestJSでの例 |
|---|---|---|---|---|
| DTO | 入出力データの運搬 | 持たない | なし | CreateUserDto |
| Entity | 永続化・業務データ | 持つことがある | IDで識別 | TypeORMの@Entity()クラス |
| DAO/Repository | データストアへのアクセス | 取得・保存処理 | 対象外 | Repository<User> |
| 値オブジェクト | ドメインの値の表現 | 不変条件を持つ | 値で比較 | Emailクラスなど |
DAOはデータを取りに行くオブジェクトで、DTOは取ってきたデータを運ぶオブジェクトです。JavaではDAOがDTOを返す組み合わせがよく使われます。Entityをそのまま返し、出力項目を制限していない場合は、追加したプロパティがAPIの応答にも含まれるおそれがあります。JPAやEF CoreでのEntityの書き方はエンティティクラスの書き方とDTOとの違いで、TypeScriptでのEntity定義はTypeORMのエンティティ・リレーションの使い方で扱っています。
NestJSでDTOをclassで定義する方法
interfaceではなくclassを使う理由
NestJS公式ドキュメントのControllersの章は、DTOをinterfaceではなくclassで定義するよう推奨しています。TypeScriptのinterfaceはトランスパイル時に消えます。そのため、実行時に型情報(metatype)を必要とするPipeからは参照できません。classはJavaScriptの構文としてコンパイル後も残ります。
同じ理由で、実行時の型情報をジェネリクスやinterfaceに依存する部分は、ValidationPipeで正しく検証できない場合があります。@Body() dtos: CreateUserDto[]のような配列も対象外です。公式ドキュメントは、配列をプロパティに持つラッパークラスを作るか、ParseArrayPipeを使うよう案内しています。
class-validatorでDTOを定義する例
検証ルールは、class-validatorのデコレーターでプロパティに付けます。ネストしたオブジェクトは、@ValidateNested()とclass-transformerの@Type()をセットで指定しないと中身が検証されません。
@ValidateNested()は、プロパティが存在するときに中身を検証するだけです。検証では、@IsDefined()を外すとaddressを丸ごと省いたリクエストが201で通りました。@IsDefined()を付けるとaddress should not be null or undefinedで400になります。必須のネスト項目には@IsDefined()を付けてください。
// src/users/dto/create-user.dto.ts
import { Type } from 'class-transformer';
import {
IsDefined, IsEmail, IsInt, IsOptional, IsString, Length, Max, Min, ValidateNested,
} from 'class-validator';
export class AddressDto {
@IsString()
city: string;
}
export class CreateUserDto {
@IsString()
@Length(1, 50)
name: string;
@IsEmail()
email: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(150)
age?: number;
@IsDefined()
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}
tsconfig.jsonではexperimentalDecoratorsとemitDecoratorMetadataを有効にします。この例はstrictPropertyInitialization: falseを前提にしています。有効にする場合は、検証で値が設定される必須プロパティをname!: stringのように宣言します。依存パッケージはnpm i class-validator class-transformerで追加します。検証時点(2026年9月17日)のnpmの最新版は、@nestjs/core 12.0.3(2026年9月15日公開)、class-validator 0.15.1、class-transformer 0.5.1です。NestJS 12の@nestjs/coreは、package.jsonに"type": "module"を持つESMパッケージです。そのため、本記事の検証ではmodule: "nodenext"を指定し、相対importに.js拡張子を付けています。
PartialType・OmitTypeによる更新用DTOの派生
PATCH用のDTOを手で書き直すと、作成用DTOとルールがずれていきます。@nestjs/mapped-typesの関数を使うと、元のDTOの検証ルールを引き継いだまま派生クラスを作れます。
// src/users/dto/update-user.dto.ts
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto.js';
// email を除き、残りを全て任意項目にする
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['email'] as const),
) {}
| 関数 | 生成されるクラス |
|---|---|
PartialType(A) |
Aの全プロパティを任意にする |
PickType(A, keys) |
指定したプロパティだけを持つ |
OmitType(A, keys) |
指定したプロパティを除く |
IntersectionType(A, B) |
AとBのプロパティを合わせる |
検証では、{"name":"Jiro"}だけを送ったPATCHが200で通りました。作成用DTOでは必須のaddressが、任意項目に変わっています。一方、{"name":"","address":{}}は、作成用DTOと同じ@Lengthとaddress.cityのルールで400になりました。値を送った項目には元のルールが適用されますが、PartialTypeは既定でnullとundefinedの検証をスキップします。元の必須項目でnullを拒否したい場合は、第2引数に{ skipNullProperties: false }を指定します。forbidNonWhitelistedを有効にした状態でemailを送ると、property email should not existで400になりました。
Swaggerを使うアプリでは、同名の関数を@nestjs/swaggerからimportします。公式ドキュメントは、@nestjs/swaggerを使うアプリで@nestjs/mapped-types版を使うと、ドキュメントに記載のない副作用が出ることがあると警告しています。OpenAPI自体の書き方はOpenAPIとSwaggerの違い・仕様書の書き方で解説しています。
ValidationPipeのオプション別の挙動(NestJS 12.0.3で実測)
以下の結果は、Node.js v26.5.0・TypeScript 7.0.2の環境で、上記のDTOを受け取るPOSTエンドポイントにcurlでJSONを送って確かめたものです。ValidationPipeはmain.tsでグローバルに登録しました。
// src/main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
whitelistとforbidNonWhitelistedの組み合わせ
DTOに定義していないisAdminと、ネスト先に定義していないaddress.zipを含むボディを送った結果です。
| 設定 | 結果 | ハンドラーが受け取る値 |
|---|---|---|
| 指定なし | 201 | isAdmin・zipが残る |
| forbidNonWhitelistedのみ | 201 | isAdmin・zipが残る |
| whitelistのみ | 201 | isAdmin・zipを削除 |
| whitelist+forbidNonWhitelisted | 400 | ハンドラーは実行されない |
公式ドキュメントも、forbidNonWhitelistedはwhitelist: trueと組み合わせて使うオプションだと説明しています。単独で指定すると、エラーにも削除にもなりません。roleやisAdminをリクエストから書き換えられる、いわゆるMass Assignmentを防げない状態です。4行目の設定では、エラーメッセージはproperty isAdmin should not existとaddress.property zip should not existの2件でした。
whitelistで削除されるのは、適用対象のclass-validatorのデコレーターが付いていないプロパティです。class-transformerの@Type()だけでは保持されません。検証ルールが不要な項目でも、DTOに残したいなら@IsOptional()や@Allow()を付けます。
transformとenableImplicitConversionの違い
transform: false(既定)では、ハンドラーが受け取る値はCreateUserDtoのインスタンスではなく、プレーンなオブジェクトでした(instanceofがfalse)。transform: trueにすると、DTOクラスのインスタンスになります。
一方、文字列の"20"を送ったageは、transform: trueだけでは数値に変換されず、age must be an integer numberで400になりました。DTO全体で宣言どおりの型に変換するには、transformOptions: { enableImplicitConversion: true }を追加します。この設定でageは20になりました。
ただし、暗黙変換は型違いの入力も検証に通します。同じ設定で{"name":123,"address":{"city":456}}を送ると、"123"と"456"という文字列に変換されて201で通りました。型違いを400にしたい項目がある場合は、全体の暗黙変換は使わず、変換が必要な項目だけに@Type()を指定してください。また、この条件でwhitelistを外すとisAdmin: trueが残ったまま通ったので、変換を有効にする場合もwhitelistは外さないでください。
この例の単一のクエリ値は文字列として解析されます。同名パラメーターの反復やパーサー設定によっては配列などにもなります。ページ番号は、次のように@Type(() => Number)で数値に変換します。
// src/users/dto/list-users.query.ts
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
export class ListUsersQuery {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
}
GET /users?page=2を送り、typeof query.pageを確認しました。オプションなしのValidationPipeでは"string"、whitelist: trueを指定するとtransform: falseのままでも"number"でした。NestJSのvalidation.pipe.jsは、内部のvalidatorOptionsに、既定で存在するforbidUnknownValues以外のキーが追加されていると、変換後のオブジェクトをclassToPlainで戻して返すためです。transformの有無だけで型を判断しないようにしてください。
ValidationPipeのエラー応答:ステータス・件数・形式
| オプション | 既定 | 実測での変化 |
|---|---|---|
errorHttpStatusCode |
400 | 422を指定するとUnprocessable Entity |
stopAtFirstError |
false | 1プロパティにつき最初の1件だけ返る |
errorFormat |
‘list’ | ‘grouped’でプロパティ名をキーにした形 |
disableErrorMessages |
false | trueで詳細メッセージを返さない |
errorFormatは、NestJS 12の型定義と公式ドキュメントに載っているオプションです。'grouped'では、messageが{"address.city":["city must be a string"]}のようなオブジェクトになりました。キーがパスを持つぶん、メッセージから親のaddress.が外れます。フロントエンドで入力欄ごとにエラーを出すなら'grouped'のほうが扱いやすくなります。本番でスキーマを外部に見せたくない場合はdisableErrorMessages: trueを指定します。
ZodでDTOを定義する場合(StandardSchemaValidationPipe)
NestJS 12の@nestjs/commonにはStandardSchemaValidationPipeがあります。Zod、Valibot、ArkTypeなど、Standard Schemaに準拠したライブラリのスキーマで検証するPipeです。公式ドキュメントは、スキーマがclassベースのDTOとは別に定義されている場合に使い、class-validatorのデコレーターに依存するプロジェクトではValidationPipeを使い続けるよう案内しています。
import { Body, Controller, Post, StandardSchemaValidationPipe } from '@nestjs/common';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(1),
age: z.coerce.number().int().min(0),
// 注意: null・空文字列・falseも0に変換される
});
type CreateUserDto = z.infer<typeof createUserSchema>;
@Controller('users')
export class UsersController {
@Post()
create(@Body({ schema: createUserSchema }) body: CreateUserDto) {
return body;
}
}
// main.ts
app.useGlobalPipes(new StandardSchemaValidationPipe());
zod 4.6.5で{"name":"Taro","age":"20","isAdmin":true}を送ると、{"name":"Taro","age":20}が返りました。z.object()は未定義のキーを既定で取り除くので、whitelistに相当する設定は要りません。型はz.inferで得られるので、スキーマと型の二重管理が起きません。一方、このDTOはclassではないため、PartialTypeは使えません。部分更新にはcreateUserSchema.partial()を使います。
フロントエンドと同じZodスキーマを共有したい場合はStandardSchemaValidationPipeが向いています。既存のclass-validatorのDTOが多く、スキーマ共有などの移行目的がないプロジェクトでは、ValidationPipeを維持する方針を勧めます。
よくある質問
DTOは何の略ですか?
Data Transfer Objectの略で、日本語では「データ転送オブジェクト」と訳されます。層やプロセスの間でデータをまとめて運ぶためのオブジェクトで、業務ロジックは持たせません。
DTOとDAOの違いは何ですか?
DAO(Data Access Object)はデータベースなどへのアクセス処理をまとめたオブジェクトで、DTOはそこで取得したデータを運ぶ入れ物です。DAOが検索結果をDTOに詰めて返す、という組み合わせで使われます。
forbidNonWhitelistedを設定しても400になりません。なぜですか?
whitelist: trueを同時に指定していないためです。NestJS 12.0.3での検証では、forbidNonWhitelisted: trueだけでは、未定義のプロパティを含むリクエストがそのまま201で通りました。
EntityをそのままDTOとして使ってもよいですか?
おすすめしません。Entityを入出力に兼用し、公開項目や更新可能項目を制限していない場合は、列の追加がAPIの応答や入力の受け付け範囲に影響するおそれがあります。パスワードハッシュのような内部項目の漏えいや、isAdminのような項目の書き換えにつながります。入力用と出力用のDTOを分けてください。
JavaではDTOをどう書きますか?
Java 16以降では、DTOをrecordで定義できます。アクセサー、equals、hashCodeが自動で生成され、各コンポーネントのフィールドがfinalとなり再代入できないため、値を運ぶDTOに向いています。ただし、参照先のListなどの内容まで不変になるわけではありません。書き方はJavaのrecordの使い方と制限で解説しています。