skip to content

Legacy experimentalDecorators

The pre-standard decorator form still required by Angular, NestJS and TypeORM, enabled by the experimentalDecorators flag. Knowing why big frameworks are stuck on it — and that the two forms cannot be mixed — is exactly what interviewers are probing.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

5

With experimentalDecorators enabled in TypeScript, a legacy method decorator is called with three arguments (target, propertyKey, descriptor). What is each argument, and what does returning a value from the decorator do?

level: middleimportance: must knowfreq 62%

answer

  1. three arguments, none of them an instance
  2. prototype for instance, constructor for static
  3. the third argument describes the member
  4. read value, wrap it, forward this
  5. returning it replaces the definition

basics

~20 s

TypeScript's legacy method decorator receives target (the prototype for an instance method, the constructor function for a static one), propertyKey (the member name), and the member's PropertyDescriptor. Returning a descriptor replaces the member's definition; returning undefined leaves it in place.

solid answer

~50 s

Under `experimentalDecorators`, a method decorator is a function `(target, propertyKey, descriptor)`. `target` is the object the member lives on — the class prototype for an instance method, the constructor function itself for a `static` method — so it is never an instance. `propertyKey` is the member's name as a string or symbol. `descriptor` is the property descriptor the class body produced, whose `value` holds the method function. The usual pattern is to read `descriptor.value`, replace it with a wrapper that calls the original through `apply` so `this` and the arguments survive, and either mutate the descriptor in place or return it. If the decorator returns a descriptor, TypeScript's emitted helper uses that as the member's definition; returning `undefined` means the descriptor object you were handed is used. It all runs once, at class-definition time, not per call.

code

typescript · 25 lines
typescript
function measure(
  target: object,
  propertyKey: string,
  descriptor: PropertyDescriptor,
): PropertyDescriptor {
  const original = descriptor.value as (...args: unknown[]) => unknown;
  descriptor.value = function (this: unknown, ...args: unknown[]) {
    const start = Date.now();
    try {
      return original.apply(this, args);
    } finally {
      console.log(`${propertyKey} took ${Date.now() - start}ms`);
    }
  };
  return descriptor;
}

class Repo {
  @measure
  find(id: string) {
    return { id };
  }
}

new Repo().find('a');

go deeper

for a junior

Be able to read a decorator someone else wrote: recognise that the function takes the owner object, the member name, and a descriptor, and that descriptor.value is the method itself.

for a middle

Explain each argument precisely, including that target is the prototype for instance members and the constructor for static ones, and show the wrap-and-forward pattern using apply so the receiver survives.

for a senior

Be ready to discuss what a wrapper silently costs in production: the declared signature no longer matches runtime behaviour, stack traces and this-binding change, and every instance shares one prototype-level wrapper.

for a principal

Own the policy question of whether cross-cutting concerns belong in decorators at all, given that the type layer cannot see what a decorator does and the behaviour becomes invisible at the call site.

## The setting Legacy decorators are the pre-standard decorator form TypeScript has shipped for years behind the `experimentalDecorators` compiler option. They are still the form Angular, NestJS and TypeORM are built on. A decorator is just a function that the compiler arranges to call when the class is *defined*, handing it enough information to inspect or replace the thing it was attached to. Decorators are one of the few TypeScript features that are **not** erased: the compiler emits real runtime code (a `__decorate` helper) that applies them. Everything else in this tree disappears at compile time; a decorator does not. ## The three parameters of a method decorator ```ts function dec(target: object, propertyKey: string, descriptor: PropertyDescriptor) {} ``` **`target`** is the object that *owns* the member. For an instance method that is the class **prototype**; for a `static` method it is the **constructor function** itself. It is never an instance — no instance exists yet when decorators run. This trips people up constantly: you cannot read per-instance state from a decorator, because there is no instance to read. **`propertyKey`** is the member's name, typed `string | symbol`. It is the only handle you get on *which* member was decorated, so it is what you key logs, registries or metadata by. **`descriptor`** is a `PropertyDescriptor` — the same shape `Object.getOwnPropertyDescriptor` returns. For a method, `descriptor.value` is the function object, and `writable`, `enumerable` and `configurable` are the attribute flags. Class methods are non-enumerable and configurable, which is what makes them replaceable at all. ## Returning a value The emitted helper treats the decorator's return value as the new descriptor when it is not `undefined`. So both of these work: ```ts descriptor.value = wrapped; // mutate in place, return nothing return { ...descriptor, value: wrapped }; // return a replacement ``` The TypeScript type for this is `MethodDecorator`, whose return type is `TypedPropertyDescriptor<T> | void`. Mutating in place is the common idiom because it preserves the flags you did not care about. The canonical wrapper reads the original function out first, then installs a replacement that forwards through `apply`: ```ts const original = descriptor.value; descriptor.value = function (this: unknown, ...args: unknown[]) { return original.apply(this, args); }; ``` Using `apply` (or `call`) matters: the wrapper must pass its own `this` through, or the method loses the receiver it was invoked on. An arrow function here would capture the wrong `this` entirely. ## The other legacy shapes The three-argument form is specific to methods and accessors. The sibling forms differ: - **Class decorator** — one argument, the constructor function. - **Property decorator** — two arguments, `(target, propertyKey)`. There is **no descriptor**, because at definition time a declared field has no initialised value to describe, and the return value is **ignored**. A property decorator can therefore only record something; it cannot wrap or redefine the field through its return. - **Accessor decorator** — the same three arguments as a method, but the descriptor carries `get`/`set` instead of `value`. TypeScript only lets you decorate **one** of a get/set pair for a given name; decorating both is a compile error, because there is a single descriptor for the pair. ## What the decorator does *not* change A legacy decorator has no effect on the static type of what it decorates. If your wrapper changes the method's signature — say it makes a synchronous method return a promise — the compiler still sees the original signature. The type layer models the class as written; the decorator's runtime substitution is invisible to it. This is a genuine soundness hole to be aware of, not a bug you can configure away. ## Timing Decorators run **once**, while the class declaration is being evaluated — not on each call and not per instance. A wrapper installed on the prototype is shared by every instance. If you need per-instance behaviour, the wrapper has to derive it from `this` at call time. ## Why interviewers ask this The three-argument signature is the fastest way to tell whether someone has actually written a decorator or has only ever applied framework ones. The follow-up is usually about `target` being the prototype, because that single fact rules out half of the wrong mental models.

  • Why does a property decorator get only two arguments instead of three?
    Because a declared field has no method to describe when the class is defined — there is no function value to wrap, and field initialisers run per instance, later. So the legacy property decorator receives only `(target, propertyKey)`, its return value is ignored, and it can do nothing but record the member somewhere for other code to act on.
  • What breaks if the replacement function is written as an arrow function instead of a regular function?
    An arrow function captures `this` lexically from the decorator's own scope, so the wrapper never receives the instance the method was called on. Every property access on `this` inside the original method then fails or reads the wrong object. The wrapper must be a regular function and forward its receiver with `original.apply(this, args)`.
  • If a decorator wraps a synchronous method so it returns a promise, what does the compiler think the method returns?
    The original synchronous type. Type checking is done against the class as written; the decorator's runtime substitution is never reflected back into the declared signature. Callers will be type-checked against the un-wrapped shape and can silently misuse the result, which is why decorators that change a signature are a known soundness hole rather than a supported pattern.

saying these in an interview costs you the question

  • Says target is the instance the method was called on
  • Thinks the decorator runs on every method call
  • Expects the wrapper's new signature to change the method's type
  • Uses an arrow function wrapper and loses this
  • Believes decorators are erased like the rest of the type system

context

open as a page

In TypeScript with experimentalDecorators enabled, what is the difference between writing @log and @log('debug') above a class method, and how must the log function be written in each case?

level: juniorimportance: should knowfreq 50%

basics

~20 s

@log applies the log function itself as the decorator, so log must have the decorator signature. @log('debug') calls log first and applies whatever it returns, so log must be a factory: a function taking 'debug' and returning the actual decorator function.

open as a page

With experimentalDecorators enabled in TypeScript, what happens at runtime when a legacy class decorator returns a new constructor function, and why does the decorated class's static type not reflect the change?

level: middleimportance: should knowfreq 38%

basics

~20 s

Returning a constructor from a legacy class decorator replaces the class binding at runtime, so new instances come from the returned constructor — commonly a subclass that adds members. The compiler ignores the return: the class keeps the type it was declared with, so added members are type errors.

open as a page

In TypeScript with experimentalDecorators enabled, what arguments does a legacy parameter decorator receive, and why can it not inspect or change the argument value passed at call time?

level: middleimportance: should knowfreq 42%

basics

~20 s

A legacy parameter decorator is called with (target, propertyKey, parameterIndex) once, when the class is defined — propertyKey is undefined for constructor parameters. No call has happened yet and its return value is ignored, so it can only record the position for other code to use later.

open as a page

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%

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.

open as a page