Skip to content
TypeScript ts decorators 4 min read

Introduction to Decorators

A decorator is a function that observes or modifies a class and its members at definition time, using the @expression syntax placed directly above the declaration. They let you attach reusable behavior — logging, validation, dependency injection, ORM mapping — without cluttering the class body. The catch is that TypeScript ships two different decorator systems: the modern Stage 3 decorators standardized by TC39 and supported natively since TypeScript 5.0, and the older legacy decorators gated behind the experimentalDecorators flag. They have incompatible signatures, so the first thing to learn is which one you are using and why.

The two decorator systems

Stage 3 decorators follow the ECMAScript proposal that is on track to become part of the language itself. Legacy decorators implement an older draft and survive mainly because large frameworks — NestJS, Angular, and TypeORM — were built on them and rely on companion features the new standard does not include. Choosing between them is usually dictated by your framework, not personal taste.

AspectStage 3 (TS 5.0+)Legacy (experimentalDecorators)
Flag requiredNone (default)experimentalDecorators: true
Decorator signature(value, context)(target, key, descriptor/index)
Parameter decoratorsNot supportedSupported
emitDecoratorMetadataNot supportedSupported (with reflect-metadata)
Used byNew, framework-free codeNestJS, Angular, TypeORM
StandardizationAligned with TC39 proposalFrozen older draft

If a single file mixes both styles, TypeScript follows whichever mode the tsconfig.json selects. You cannot use both systems in the same compilation.

Enabling Stage 3 decorators

Stage 3 decorators need no special flag in TypeScript 5.0 or later — just make sure experimentalDecorators is off (the default). Set a reasonable target so the emitted output is clean.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext"
    // experimentalDecorators NOT set -> standard decorators
  }
}

With this configuration, a decorator receives the decorated value plus a strongly typed context object. The example below logs whenever a method runs.

function logged<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This>,
) {
  return function (this: This, ...args: Args): Return {
    console.log(`Calling ${String(context.name)}`);
    return target.call(this, ...args);
  };
}

class Greeter {
  @logged
  greet(name: string) {
    return `Hello, ${name}`;
  }
}

The context argument carries metadata such as kind, name, and addInitializer. Every Stage 3 decorator has a context parameter — that is the easiest way to recognize the new system at a glance.

Enabling legacy decorators

Frameworks like NestJS still require the legacy system, which you opt into with experimentalDecorators. They almost always pair it with emitDecoratorMetadata plus the reflect-metadata package so the runtime can read constructor parameter types.

{
  "compilerOptions": {
    "target": "ES2021",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Legacy decorators receive positional arguments instead of a context object. A method decorator, for instance, gets the prototype, the property key, and the property descriptor.

function Log(target: object, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function (...args: unknown[]) {
    console.log(`Calling ${key}`);
    return original.apply(this, args);
  };
}

class Service {
  @Log
  run(task: string) {
    return `running ${task}`;
  }
}

Notice there is no context parameter and the descriptor is mutated in place. This shape is fundamentally incompatible with Stage 3, so decorators written for one system will not type-check under the other.

Decorator kinds

Both systems can decorate classes, methods, accessors, and properties (fields). Only the legacy system adds parameter decorators, which is the main reason dependency-injection frameworks still depend on it.

// Stage 3: a class decorator that seals the constructor
function sealed(value: Function, context: ClassDecoratorContext) {
  Object.seal(value);
  Object.seal(value.prototype);
}

@sealed
class Repository {}

The remaining pages in this section cover each kind in detail, always showing the Stage 3 form first and the legacy form where it differs.

Best Practices

  • Default to Stage 3 decorators for new code; reach for legacy only when a framework demands it.
  • Never combine both systems — pick one per tsconfig.json and keep it consistent.
  • Enable emitDecoratorMetadata only with experimentalDecorators; it does nothing for Stage 3.
  • Recognize the system at a glance: a context parameter means Stage 3, positional args mean legacy.
  • Keep decorators small and side-effect-light; prefer returning a wrapper over mutating in place.
  • Import reflect-metadata exactly once at the application entry point when using legacy metadata.
  • Document which decorator system a shared library targets so consumers configure their compiler correctly.
Last updated June 29, 2026
Was this helpful?