第2章: Decorator と Reflect.metadata ─ 二層構造を読み解く
第1章で DI コンテナが Reflect.getMetadata() を呼んで Provider の依存を解決すると書いた。その前に誰がメタデータを書き込んだのか? 答えは Decorator だ。本章では NestJS の Decorator が「メタデータ宣言と実行時 hook」の二層で動く仕組みを分解する。
これを理解すると:
- なぜ
@Injectable()を付けるだけで DI が効くのか - Custom Decorator を自分で書ける
- TypeScript の Stage 3 Decorator になぜ NestJS が乗らないのか
- DiscoveryService で「特定 Decorator が付いた全 method を集める」ような自前フレームワーク的拡張ができる
Decorator とは「メタデータ宣言 + 実行時 hook」
NestJS の Decorator は、機能的に2層で動く。
graph TB
Dec[Decorator]
Dec --> Meta[層 1: メタデータ宣言<br/>Reflect.defineMetadata]
Dec --> Hook[層 2: 実行時 hook<br/>関数を返してパラメータ加工等]
Meta --> Read[NestJS が起動時に Reflect.getMetadata で読む]
Hook --> Run[リクエストごとに実行される]
style Meta fill:#1a2030,stroke:#4cc9f0
style Hook fill:#1a2030,stroke:#ff4d6d
たとえば:
@Module(metadata)→ 層1のみ:メタデータを class に貼るだけ@Get('/users')→ 層1のみ:HTTP メソッドとパスをメソッドに貼るだけ@Body()→ 層2あり:リクエストから body を取り出すロジックを実行
「Decorator = 関数」と覚えると、何が起きているかが見えやすい。
層1:メタデータの格納場所
Reflect.defineMetadata(key, value, target) は、内部的には target オブジェクトに直接プロパティを生やしている。reflect-metadata ポリフィルの実装は target[Symbol(metadataKey)] のような形でメタデータマップを保持する。
@Injectable()
export class CatsService {
constructor(private readonly repo: CatsRepository) {}
}
これがコンパイルされると、概念的には:
let CatsService = class CatsService {
constructor(repo) { this.repo = repo; }
};
CatsService = __decorate([
Injectable(),
__metadata("design:paramtypes", [CatsRepository])
// ↑ ここが鍵:TypeScript が自動生成する型情報
], CatsService);
__metadata("design:paramtypes", [CatsRepository]) がコンパイル時に自動生成される。これが「TypeScript の emitDecoratorMetadata を有効にするとコンストラクタの型情報が runtime に出る」の正体。
NestJS の DI コンテナは:
const types = Reflect.getMetadata('design:paramtypes', CatsService);
// → [CatsRepository]
を読んで「CatsService のコンストラクタには CatsRepository を渡せばいい」と理解する。@Injectable() の本質的な役割は、TypeScript に design:paramtypes を出させることだ。
tsconfig.json の必須項目
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
この 2 つが揃っていないと Reflect.getMetadata('design:paramtypes', target) が undefined を返し、DI が壊れる。
TypeScript Stage 3 Decorator となぜ乗らないか
TypeScript 5.0(2023-03)で TC39 Stage 3 Decorator がサポート開始された。Stage 3 は ECMAScript 標準化を視野に入れた仕様で、context.metadata という形でメタデータを直接書き込めるようになった。
「理論上は reflect-metadata 不要」と言われるが、NestJS は依然として experimentalDecorators + emitDecoratorMetadata(Stage 2 互換) を使っている。理由は明確だ。
| Stage 2(現行 NestJS) | Stage 3(TS 5.x で標準化進行中) |
|---|---|
__decorate + __metadata でコンパイル時に型情報を出す | コンストラクタ引数の型情報を runtime に出す標準仕様がない |
Reflect.metadata ポリフィルが業界標準 | context.metadata は class / method 単位のみ |
@nestjs/typeorm、@nestjs/mongoose、class-validator など周辺パッケージが Stage 2 前提 | エコシステム移行に時間がかかる |
要するに 「コンストラクタ引数の型情報を実行時に得る」標準的な手段が Stage 3 にはまだない。これがある限り、NestJS は Stage 2 を使い続ける可能性が高い。
ただし、v12 ロードマップでは ESM / Vitest / Rspack 移行とともに Standard Schema 対応(Zod / Valibot / ArkType を @Body() で直接使える)が予定されている。Decorator 仕様の動向は引き続き注目。
主要 Decorator の実体
NestJS の主要 Decorator が「何をしているか」を内部的に見てみる(簡略版):
// @Module(metadata) の概念実装
function Module(metadata: ModuleMetadata): ClassDecorator {
return (target: Function) => {
Reflect.defineMetadata(MODULE_METADATA.IMPORTS, metadata.imports || [], target);
Reflect.defineMetadata(MODULE_METADATA.PROVIDERS, metadata.providers || [], target);
Reflect.defineMetadata(MODULE_METADATA.CONTROLLERS, metadata.controllers || [], target);
Reflect.defineMetadata(MODULE_METADATA.EXPORTS, metadata.exports || [], target);
};
}
// @Controller(prefix) の概念実装
function Controller(prefix: string): ClassDecorator {
return (target: Function) => {
Reflect.defineMetadata(PATH_METADATA, prefix, target);
};
}
// @Injectable() の概念実装
function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: Function) => {
Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
if (options?.scope) {
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
}
// ↑ ここに加えて、TypeScript が __metadata("design:paramtypes", [...]) を出す
};
}
NestJS が起動時に Reflect.getMetadata() で読み取り、Module ツリーを構築する。
Custom Decorator を作る
メタデータを「読む側」を理解したら、「書く側」も書けるようになる。NestJS には主に 3 つの Custom Decorator パターンがある。
1. SetMetadata:シンプルなメタデータ付与
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// 使う側
@Roles('admin')
@Get()
findAll() {}
Reflect.getMetadata('roles', handler) で取り出せる。
2. Reflector.createDecorator<T>:型付き Decorator(v9 以降)
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
// 使う側
@Roles(['admin'])
@Get()
findAll() {}
// 読む側
const roles = this.reflector.get(Roles, context.getHandler());
// ↑ roles は string[] と推論される(型安全)
文字列キーで管理する必要がなく、TypeScript の型推論が効く。NestJS v9 以降の推奨方式。
3. createParamDecorator:パラメータ Decorator
@Body() や @Param() のような、リクエストから値を取り出す Decorator。
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return data ? request.user?.[data] : request.user;
},
);
// 使う側
@Get()
profile(@User() user: UserEntity) {} // user オブジェクト全部
@Get('email')
email(@User('email') email: string) {} // email だけ
これが「層 2:実行時 hook」の典型。ファクトリ関数がリクエストごとに実行されて値を返す。
Reflector ─ 読み取りの API
メタデータを読む API は Reflector クラスにまとまっている。
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 1. 単一メタデータ取得
const roles = this.reflector.get(Roles, context.getHandler());
// 2. 複数ターゲットからマージ(v11 でオブジェクト直返し)
const merged = this.reflector.getAllAndMerge(Roles, [
context.getHandler(),
context.getClass(),
]);
// 3. handler 優先で override
const overridden = this.reflector.getAllAndOverride(Roles, [
context.getHandler(),
context.getClass(),
]);
return /* 判定ロジック */;
}
}
v11 の変更点:
getAllAndMergeが単一要素配列ではなくオブジェクトを直接返すgetAllAndOverrideの戻り値型がT | undefinedに
DiscoveryService と MetadataScanner ─ 自前フレームワーク的拡張
ここまで来ると「特定 Decorator が付いた全 method を起動時に集める」ような自前フレームワーク的な拡張が書けるようになる。
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
@Injectable()
export class EventListenerLoader implements OnModuleInit {
constructor(
private discovery: DiscoveryService,
private scanner: MetadataScanner,
) {}
onModuleInit() {
const providers = this.discovery.getProviders();
for (const wrapper of providers) {
const { instance } = wrapper;
if (!instance) continue;
const prototype = Object.getPrototypeOf(instance);
this.scanner.getAllMethodNames(prototype).forEach(method => {
const eventName = Reflect.getMetadata('event_listener', prototype[method]);
if (eventName) {
// この method が @EventListener(eventName) を持つ
this.register(eventName, instance, method);
}
});
}
}
}
これが NestJS の EventEmitterModule(@OnEvent)、ScheduleModule(@Cron)、@nestjs/cqrs(@CommandHandler)の内部で実際に行われていることだ。
つまり、@OnEvent のような「Decorator + 起動時収集」パターンは自分でも書ける。社内独自の Annotation 駆動 Framework を作るときの基盤になる。
メタデータのメモリコスト
各 class / method / property に Map が生えるため、大規模アプリ(数千 provider)では起動時にメタデータの shallow copy で MB 単位のメモリを消費する。
NestJS v11 で Module 解決アルゴリズムがオブジェクト参照ベースに変更されたのは、これを軽減して startup を高速化する目的のひとつ。第4章で扱う。
✅ 良い使い方 / ❌ 悪い使い方
// ❌ 悪い:文字列キーが散らばる
SetMetadata('isPublic', true);
const isPublic = reflector.get<boolean>('isPublic', handler);
// ✅ 良い:Reflector.createDecorator で型安全
export const IsPublic = Reflector.createDecorator<boolean>();
const isPublic = this.reflector.get(IsPublic, handler);
// ❌ 悪い:Custom ParamDecorator で重い処理
export const ExpensiveUser = createParamDecorator(
async (data, ctx) => {
return await db.users.findOne(/* ... */); // ← リクエストごとに DB 引く
},
);
// ✅ 良い:パラメータ Decorator は純粋に context から値を取るだけ
export const User = createParamDecorator(
(data, ctx) => ctx.switchToHttp().getRequest().user,
);
// 重い処理は Guard / Interceptor / Service に
本章の要点
| # | 要点 |
|---|---|
| 1 | NestJS の Decorator は 「メタデータ宣言 + 実行時 hook」の二層 |
| 2 | @Injectable() の本質は「TypeScript に design:paramtypes を出させること」 |
| 3 | experimentalDecorators + emitDecoratorMetadata の 両方が必須(片方だけだと DI が壊れる) |
| 4 | Stage 3 Decorator にまだ乗らない理由:コンストラクタ引数の型情報を runtime に出す標準仕様がない |
| 5 | Custom Decorator のパターン:SetMetadata / Reflector.createDecorator<T>(v9+ 推奨) / createParamDecorator |
| 6 | v11 で Reflector.getAllAndMerge がオブジェクト直返しに変更 |
| 7 | DiscoveryService + MetadataScanner で 「特定 Decorator が付いた全 method を集める」自前フレームワークが書ける |
| 8 | パラメータ Decorator では重い処理を書かない。コンテキストから値を取るだけにする |
効いている根本原理
本章は 原理2(Decorator は「メタデータ宣言 + 実行時 hook」の二層) を分解した章だった。@Injectable() がなぜ動くのか、Custom Decorator をどう作るかが分かれば、@OnEvent / @Cron / @CommandHandler がやっていることも自分の手で再現できる。
第1章の DI コンテナと本章の Decorator が起動時の話だったのに対し、次章は実行時の話 ─ リクエストが来てからレスポンスを返すまでの 6 つの層を順序通りに分解する。