Skip to content

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?

Code for this question
// 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 {}
Pick one

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

Two meanings of "decorator". A decorator object wraps one instance; an @ 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?

Code for this question
// 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 {}
Pick one

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?

Code for this question
// 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 {}
Pick one

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?

Code for this question
// 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 {}
Pick one

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?

Code for this question
// 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 {}
Pick one

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?

Code for this question
// 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 {}
Pick one

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?

Code for this question
// 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 {}
Pick one

Go deeper

Pro tips
  1. When an injected field is undefined under one runner only, read the compiled file before the module. Search the emitted JavaScript for design: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).
  2. 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 useFactory that returns new CachingGrader(inner) (pat-factory-provider owns useFactory). 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.

Checked against the tools and docs as of 2026-10-13.