skip to content

A TypeScript project sets experimentalDecorators to true because it uses NestJS. A new library ships decorators written against the standard TC39 form. Can the project use both kinds of decorator, and what determines the answer?

level: seniorimportance: should knowfreq 50%

answer

  1. one switch, whole program
  2. two features, not two syntaxes
  3. the signatures disagree entirely
  4. one form has parameters, the other does not
  5. separate the compilation, not the file

basics

~20 s

No. experimentalDecorators is a whole-compilation switch: with it on, every decorator in the program is compiled with legacy semantics, so a standard-form decorator expecting (value, context) receives (target, key, descriptor) instead and misbehaves. There is no per-file or per-decorator opt-in.

solid answer

~50 s

They cannot coexist in one compilation. `experimentalDecorators` is a program-wide compiler option, not a per-file directive: when it is on, TypeScript emits the legacy `__decorate` calling convention for **every** decorator it sees. A standard TC39 decorator is written as `(value, context)` where `context` carries `kind`, `name`, `addInitializer` and friends; called the legacy way it receives the prototype, a key and a descriptor, and either throws or corrupts the class. Turning the flag off flips the whole program the other way and breaks NestJS. What keeps frameworks on legacy is concrete: **parameter decorators do not exist in the standard form at all**, and constructor-parameter injection is built on them, along with the legacy-only `emitDecoratorMetadata` emit. So the realistic answers are to keep the flag on and use a legacy-compatible entry point from the new library if it offers one, or to isolate the standard-decorator code in a separate compilation unit with its own tsconfig.

go deeper

for a junior

Know that TypeScript has two different decorator features, that experimentalDecorators selects the older one, and that a project picks one for the whole compilation.

for a middle

Explain the signature difference — legacy gets target/key/descriptor, standard gets value plus a context object — and why calling one with the other's arguments fails at class-definition time.

for a senior

Diagnose the runtime symptom, name the concrete blockers keeping DI frameworks on legacy, and reach for a separate compilation unit rather than trying to reconcile both forms in one program.

for a principal

Own the dependency policy: whether the organisation accepts libraries that are standard-only while the framework is legacy-only, and what an eventual migration would cost across every service that uses constructor injection.

## Two genuinely different features TypeScript has shipped decorators twice. The legacy form predates the standard and lives behind the `experimentalDecorators` compiler option. The standard form implements the TC39 proposal and has been available since TypeScript 5.0, used when `experimentalDecorators` is **off**. They are not two syntaxes for one feature — they differ in what the decorator receives, what it may return, and when initialisers run. | | legacy (`experimentalDecorators: true`) | standard (flag off) | |---|---|---| | method decorator gets | `(target, propertyKey, descriptor)` | `(value, context)` | | class decorator gets | `(constructor)` | `(value, context)` | | parameter decorators | supported | **not part of the proposal** | | context object | none | `{ kind, name, static, private, addInitializer, access }` | ## The switch is per program `experimentalDecorators` is a compiler option in `tsconfig.json`. There is no comment pragma, no per-file override, no per-import escape. Every decorator in the files that compilation includes is emitted with the same convention. That is what makes the question binary rather than a matter of care. Hand a standard-form decorator the legacy arguments and it reads `context.kind` off a string or a descriptor object and fails — usually with a `TypeError` at class-definition time, sometimes worse, by silently registering nothing. The inverse fails just as hard: a legacy decorator under standard semantics receives a value and a context object where it expected a prototype and a key. ## Why the big frameworks stay on legacy This is the part interviewers actually want. Two hard blockers, not inertia: **Parameter decorators do not exist in the standard proposal.** The TC39 decorators proposal covers classes, methods, accessors, fields and auto-accessors — not parameters. Constructor-parameter dependency injection, the core ergonomic of Angular and NestJS, is built entirely on parameter decorators. There is no standard-form translation of `constructor(@Inject(TOKEN) private dep: Dep)`; the feature is simply absent. **`emitDecoratorMetadata` is legacy-only.** The compiler's design-time type metadata emit is coupled to the legacy implementation and is not available with standard decorators. Frameworks that resolve dependencies from declared constructor parameter types depend on it. (The metadata mechanism itself is its own subject; what matters here is that it does not cross the boundary.) Together those mean a DI framework cannot migrate by rewriting decorator bodies — it needs a different injection design. ## What you can actually do 1. **Keep the flag on and check the library.** Well-behaved libraries that target both worlds ship two entry points or detect the calling convention. If the new library offers a legacy-compatible export, use it and the problem disappears. 2. **Split the compilation.** Move the standard-decorator code into its own package or its own tsconfig with `experimentalDecorators` off, build it separately, and consume the built output from the NestJS side. The consuming program never re-compiles it, so its emit is already fixed and the flag no longer applies to it. This is the reliable escape hatch and the one worth naming in an interview. 3. **Do not use the decorator.** Most decorator APIs have an imperative equivalent — call the underlying registration function directly. Often the cheapest fix. 4. **Migrate the whole program**, which means leaving the framework or waiting for it. Not a decision to take for one library. ## Diagnosing it in the wild The symptom is rarely a clean compile error, because both forms are syntactically identical at the use site — `@Something()` looks the same either way. You get a runtime failure at import time, in the module that declares the class, with a stack that points into the library's decorator body. When a decorator from a recently-added dependency throws on module load and the codebase has `experimentalDecorators: true`, this mismatch should be the first hypothesis. ## How to answer Lead with "no, and it is a compilation-wide switch" — that is the fact being tested. Then give the two concrete blockers that keep frameworks on the legacy form, because they show you know *why* the ecosystem is split rather than just *that* it is. Close with the separate-compilation workaround, which is the answer a senior engineer is expected to reach for instead of shrugging.

  • Why can a framework not simply detect at runtime which convention it was called with and branch?
    Some do try, by inspecting `arguments.length` and whether the second argument looks like a context object, but it is fragile and it cannot recover the features that are missing entirely. No amount of runtime detection produces parameter decorators or design-time type metadata under standard semantics, so DI frameworks gain nothing from the branch.
  • What is the practical shape of the separate-compilation workaround?
    Put the standard-decorator code in its own package or subproject with a tsconfig that leaves `experimentalDecorators` off, build it to JavaScript plus declaration files, and have the legacy program depend on the built output. Because the emit is already done, the consumer's flag never touches it, and only source that the legacy program itself compiles is affected.
  • What does the failure look like when a standard-form decorator is compiled with experimentalDecorators on?
    A runtime error while the module defining the decorated class is being evaluated, not a compile error — the syntax is identical either way. The decorator body reads context fields off arguments that are a prototype, a key and a descriptor, so it typically throws a TypeError on an undefined property, or registers nothing at all and fails later.

saying these in an interview costs you the question

  • Says you can enable the flag per file or per import
  • Treats the two forms as the same feature with a version bump
  • Claims standard decorators cover parameters too
  • Thinks a cast or a wrapper reconciles the two conventions
  • Assumes frameworks stay on legacy purely out of inertia

context