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?
answer
- three arguments, none of them an instance
- prototype for instance, constructor for static
- the third argument describes the member
- read value, wrap it, forward this
- returning it replaces the definition
basics
~20 sTypeScript'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 sUnder `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 linesfunction 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
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.
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.
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.
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