Decorator metadata: why NestJS keeps legacy decorators
Nest injects from a list the compiler writes; when a dependency is undefined, ask which compiler built the file.
Predict first
Before you read: predict
A ledger's NestJS module is compiled by tsc with the project's tsconfig.json, where experimentalDecorators and emitDecoratorMetadata are both on. The app is started with LedgerModule, then settle(1000) is called once on SettlementService. The files are below.
What happens?
// ledger/fx-rates.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class FxRates { eurPerUsd() { return 0.9; }} // ledger/settlement.service.tsimport { FxRates } from './fx-rates'; export class SettlementService { constructor(private rates: FxRates) {} settle(usdCents: number) { return Math.round(usdCents * this.rates.eurPerUsd()); }} // ledger/ledger.module.tsimport { Module } from '@nestjs/common';import { FxRates } from './fx-rates';import { SettlementService } from './settlement.service'; @Module({ providers: [FxRates, SettlementService] })export class LedgerModule {}The 30-second version
The decorator pattern wraps one object at run time; a TypeScript @ decorator is a function the class definition calls once. Nest needs more from the second kind: a list of each constructor parameter's type. Only the compiler writes it, only for a decorated class, only in legacy mode. With no list, Nest calls the constructor with nothing.
Nest injects from a list the compiler writes; when a dependency is undefined, ask which compiler built the file.
How it works
Step 1 of 12
@ decorator is a function the class definition calls once (pat-structural-wrappers steps 6, 11).Analogy: A goods yard's wagon labels (rail freight)
| In the goods yard | In code |
|---|---|
| the booking office prints a label listing a wagon's destination, but only for a wagon that arrives with a consignment note | tsc writes the list only for a decorated class (steps 3–4) |
| the shunters couple every wagon into a train by its label; one with no label is coupled too, and nothing goes wrong until a yard down the line tries to drop it off | no list: the app starts, and the first use fails (step 5) |
| a private siding's quick office labels each wagon on its own and prints no destination, even though its notes ask for one | a per-file compiler writes no list (step 11) |
| a destination chalked on the wagon side by the sender | @Inject(X) names the token in the source (step 12) |
In real terms: Nest never reads your parameter types; it reads a list that a compiler wrote beside the class, if one did.
Breaks down when: a shunter can walk over and read a wagon's paperwork; Nest never looks at the constructor's types, only at the list.
Contrasting case: the decorator pattern needs no list: the composition root builds the wrapper itself, new CachingGrader(inner), and nothing about it depends on the compiler (tip 2).
Examples, run for real
4.1 ExamHall EXH-4060: the grader's tests on a per-file compiler
ExamHall's GradingService injects AnswerKeys by its class:
// grading/answer-keys.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AnswerKeys {
keyFor(questionId: string) { return questionId === 'q1' ? 'B' : 'D'; }
}
// grading/grading.service.ts
import { Injectable } from '@nestjs/common';
import { AnswerKeys } from './answer-keys';
@Injectable()
export class GradingService {
constructor(private keys: AnswerKeys) {}
score(answers: Record<string, string>) {
return Object.entries(answers).filter(([q, a]) => this.keys.keyFor(q) === a).length;
}
}
The app is built by tsc. Its unit tests run under a runner that hands each file to esbuild alone, with the same tsconfig.json. Both runs are below.
4.1 tsc 6.0.3 build (experimentalDecorators, emitDecoratorMetadata): grading.service.js has 1 design:paramtypes call, __metadata("design:paramtypes", [answer_keys_1.AnswerKeys]) · list [AnswerKeys] · score({ q1: 'B', q2: 'D' }) returns 2
4.1 EXH-4060 tests on esbuild 0.28.2, one file at a time (the same tsconfig.json): 0 design:paramtypes calls · list none · the module starts · score throws TypeError
tsc wrote one design:paramtypes call naming AnswerKeys, and Nest passed an AnswerKeys in (steps 2–3). esbuild wrote none, so in the tests Nest called the constructor with nothing; the module started, and the first score failed (steps 5 and 11).
4.2 MR !4360: a test compiler that writes the list
MR !4360 changes no source file. It makes the runner compile with SWC, set as the NestJS SWC recipe sets it for Jest and Vitest (legacyDecorator and decoratorMetadata):
4.2 MR !4360 tests on @swc/core 1.16.12 (legacyDecorator, decoratorMetadata): 1 design:paramtypes call · list [AnswerKeys] · score({ q1: 'B', q2: 'D' }) returns 2 · source files changed 0
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
A box office's NestJS app adds its own @logged decorator, written in the standard form, to a provider that Nest injects. With the project's tsconfig.json, which turns on experimentalDecorators and emitDecoratorMetadata, tsc reports TS1241 and TS1270 at the @logged line. The team wants three things: tsc reports nothing; in the booted app hold('K') returns 'held'; and calls then holds 'hold'. The files are below.
Which one change gets all three?
// holds/logged.tsexport const calls: string[] = [];export function logged<T, A extends unknown[], R>(fn: (this: T, ...args: A) => R, ctx: ClassMethodDecoratorContext<T>) { return function (this: T, ...args: A): R { calls.push(String(ctx.name)); return fn.apply(this, args); };} // holds/seat-map.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class SeatMap { free(row: string) { return row === 'K' ? 4 : 0; }} // holds/hold.service.tsimport { Injectable } from '@nestjs/common';import { SeatMap } from './seat-map';import { logged } from './logged'; @Injectable()export class HoldService { constructor(private seats: SeatMap) {} @logged hold(row: string) { return this.seats.free(row) > 0 ? 'held' : 'full'; }} // holds/holds.module.tsimport { Module } from '@nestjs/common';import { SeatMap } from './seat-map';import { HoldService } from './hold.service'; @Module({ providers: [SeatMap, HoldService] })export class HoldsModule {}Question 2 of 6
A streaming app's player service gets a licence key for each title. The app's build runs tsc, and start('t-9') returns 'key-t-9'. Its tests now run on a runner that compiles each file with SWC, with legacyDecorator on and decoratorMetadata left off; there, a test that boots PlaybackModule gets TypeError from start('t-9'). The runner and its settings stay as they are. The files are below.
Which one change makes the test's start('t-9') return 'key-t-9', and keeps the tsc build returning it too?
// playback/licence-client.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class LicenceClient { keyFor(titleId: string) { return `key-${titleId}`; }} // playback/playback.service.tsimport { Injectable } from '@nestjs/common';import { LicenceClient } from './licence-client'; @Injectable()export class PlaybackService { constructor(private licences: LicenceClient) {} start(titleId: string) { return this.licences.keyFor(titleId); }} // playback/playback.module.tsimport { Module } from '@nestjs/common';import { PlaybackService } from './playback.service';import { LicenceClient } from './licence-client'; @Module({ providers: [PlaybackService, LicenceClient] })export class PlaybackModule {}Question 3 of 6
A chat app counts unread messages per room. Its team traces which classes are loaded with a decorator of its own. The files are compiled by tsc with experimentalDecorators and emitDecoratorMetadata on. The app is started with RoomsModule, then unread('r-1') is called once on UnreadService. The files are below.
What happens?
// rooms/traced.tsexport const traced: string[] = [];export function Traced(): ClassDecorator { return (target) => { traced.push(target.name); };} // rooms/message-store.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class MessageStore { count(roomId: string) { return roomId === 'r-1' ? 3 : 0; }} // rooms/unread.service.tsimport { Traced } from './traced';import { MessageStore } from './message-store'; @Traced()export class UnreadService { constructor(private store: MessageStore) {} unread(roomId: string) { return this.store.count(roomId); }} // rooms/rooms.module.tsimport { Module } from '@nestjs/common';import { MessageStore } from './message-store';import { UnreadService } from './unread.service'; @Module({ providers: [MessageStore, UnreadService] })export class RoomsModule {}Question 4 of 6
A ledger releases payouts, and holds any payout its fraud screen flags. The files are compiled by tsc with strict, experimentalDecorators and emitDecoratorMetadata on. The app is started with PayoutsModule, then release('po-7') is called once on PayoutService. The files are below.
What happens?
// payouts/fraud-screen.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class FraudScreen { flagged(payoutId: string) { return payoutId === 'po-7'; }} // payouts/payout.service.tsimport { Injectable } from '@nestjs/common';import { FraudScreen } from './fraud-screen'; @Injectable()export class PayoutService { constructor(private screen?: FraudScreen) {} release(payoutId: string) { return this.screen?.flagged(payoutId) ? 'held' : 'sent'; }} // payouts/payouts.module.tsimport { Module } from '@nestjs/common';import { FraudScreen } from './fraud-screen';import { PayoutService } from './payout.service'; @Module({ providers: [FraudScreen, PayoutService] })export class PayoutsModule {}Question 5 of 6
A ledger adds card settlements, which add a fee on top of a plain settlement when there is a fee table. The files are compiled by tsc with experimentalDecorators and emitDecoratorMetadata on. The app is started with LedgerModule, then settleCard(1000) is called once on CardSettlementService. The files are below.
What does settleCard(1000) do?
// ledger/rates.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class FxRates { eurPerUsd() { return 0.9; }} @Injectable()export class FeeTable { percent() { return 2; }} // ledger/settlement.service.tsimport { Injectable } from '@nestjs/common';import { FxRates } from './rates'; @Injectable()export class SettlementService { constructor(protected rates: FxRates) {} settle(usdCents: number) { return Math.round(usdCents * this.rates.eurPerUsd()); }} // ledger/card-settlement.service.tsimport { FxRates, FeeTable } from './rates';import { SettlementService } from './settlement.service'; export class CardSettlementService extends SettlementService { constructor(rates: FxRates, private fees: FeeTable) { super(rates); } settleCard(usdCents: number) { const eur = this.settle(usdCents); const fee = this.fees?.percent() ?? 0; return eur + Math.round((eur * fee) / 100); }} // ledger/ledger.module.tsimport { Module } from '@nestjs/common';import { FxRates, FeeTable } from './rates';import { CardSettlementService } from './card-settlement.service'; @Module({ providers: [FxRates, FeeTable, CardSettlementService] })export class LedgerModule {}Question 6 of 6
A theatre's waitlist reads the venue map when one is configured. The files are compiled by tsc with strict, experimentalDecorators and emitDecoratorMetadata on. The app is started with WaitlistModule, then offer('s-4') is called once on WaitlistService. The files are below.
What happens?
// waitlist/venue-map.tsimport { Injectable } from '@nestjs/common'; @Injectable()export class VenueMap { seatsLeft(showId: string) { return showId === 's-4' ? 12 : 0; }} // waitlist/waitlist.service.tsimport { Injectable } from '@nestjs/common';import { VenueMap } from './venue-map'; @Injectable()export class WaitlistService { constructor(private venue: VenueMap | null) {} offer(showId: string) { return this.venue ? `${this.venue.seatsLeft(showId)} seats` : 'no venue map'; }} // waitlist/waitlist.module.tsimport { Module } from '@nestjs/common';import { VenueMap } from './venue-map';import { WaitlistService } from './waitlist.service'; @Module({ providers: [VenueMap, WaitlistService] })export class WaitlistModule {}Go deeper
Pro tips
- When an injected field is
undefinedunder one runner only, read the compiled file before the module. Search the emitted JavaScript fordesign:paramtypes; if the call is missing, the compiler dropped the list, and Nest only did what it was given. When: a provider works in the build and fails in tests, or the other way round (steps 3 and 11). - To add caching to a service without changing the service or its callers, bind a decorator object where the providers are registered. Register the plain class under its own token, and register the interface's token with a
useFactorythat returnsnew CachingGrader(inner)(pat-factory-providerownsuseFactory). When: the curriculum row's typical question, "Add caching to a service without changing the service or its callers." Verify (RAN,tips.mjs):tip 2 tsc 6.0.3: no diagnostics · MarkingService and its callers unedited; Grader bound by useFactory to new CachingGrader(SimpleGrader) tip 2 mark('q1:B'), mark('q1:B'), mark('q1:B,q2:D') return 1, 1, 2 · SimpleGrader.score ran 2 times
Source
A web page can change, so every claim from these pages is labelled as of 2026-10-13, the date read. No source keys an item: every key comes from a run.
- TypeScript 5.0 release notes, Decorators (as of 2026-10-13), the curriculum row's second source: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html (
ts-5-0-decorators.txt). Step 8 rests on it: "This new decorators proposal is not compatible with --emitDecoratorMetadata , and it does not allow decorating parameters." - NestJS documentation, Migration guide (v11 to v12) (as of 2026-10-13), the row's third source, read from the docs site's source repository: https://docs.nestjs.com/migration-guide, fetched from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/master/content/migration.md (
nestjs-migration-v12.txt). Step 9 rests on its "completecompilerOptionsset used by the ESM project thatnest newgenerates", which lists"emitDecoratorMetadata": true,and"experimentalDecorators": true,. - TypeScript Handbook, Decorators, section Metadata (as of 2026-10-13): https://www.typescriptlang.org/docs/handbook/decorators.html (
ts-handbook-decorators.txt). Not named by the row; teaching only. - NestJS documentation, SWC recipe (as of 2026-10-13): https://docs.nestjs.com/recipes/swc, fetched from https://raw.githubusercontent.com/nestjs/docs.nestjs.com/master/content/recipes/swc.md (
nestjs-recipe-swc.txt). Not named by the row; teaching only. - esbuild documentation, Content Types, TypeScript caveats (as of 2026-10-13): https://esbuild.github.io/content-types/ (
esbuild-content-types.txt). Not named by the row; teaching only. - The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Decorator: a book; it keys nothing. The pattern itself is the prerequisite's.
Related lessons
- Read first: Wrappers: adapter, decorator, proxy, facade
- Related: Dependency inversion and NestJS dependency injection
- Related: NestJS modules, custom providers and why DI makes testing cheap
- Related: Validation at the boundary: ValidationPipe, whitelisting, mass assignment
- Related: Factory, abstract factory and NestJS useFactory
- Related: Composing resilience policies: order is behaviour
Checked against the tools and docs as of 2026-10-13.