Decorators

A decorator is a function you attach to a class or class member with @name syntax. It can observe, wrap or replace what it's attached to — adding logging, validation, caching or registration without cluttering the code itself. TypeScript 5.0 supports the standard ECMAScript decorators proposal.

A first method decorator

TS logged.ts
function logged(original: Function, context: ClassMethodDecoratorContext) {
  const name = String(context.name);
  return function (this: unknown, ...args: unknown[]) {
    console.log(`→ ${name}(${args.join(", ")})`);
    const result = original.apply(this, args);
    console.log(`← ${name} returned ${result}`);
    return result;
  };
}

class Calculator {
  @logged
  add(a: number, b: number) {
    return a + b;
  }
}

new Calculator().add(2, 3);
Output
→ add(2, 3)
← add returned 5

A method decorator receives two arguments: the original method and a context object describing it (its name, whether it's static or private, and more). If it returns a function, that function replaces the method. Here, the replacement logs around a call to the original.

Typing a decorator properly

The version above uses loose types. A generic version preserves the method's real signature:

TS typed.ts
function timed<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  return function (this: This, ...args: Args): Return {
    const start = performance.now();
    const result = target.call(this, ...args);
    const ms = performance.now() - start;
    console.log(`${String(context.name)} took ${ms < 50 ? "<50" : Math.round(ms)}ms`);
    return result;
  };
}

class Report {
  @timed
  build(rows: number) {
    let total = 0;
    for (let i = 0; i < rows; i++) total += i;
    return total;
  }
}

console.log(new Report().build(1000));
Output
build took <50ms
499500

Now the compiler would reject applying @timed somewhere it doesn't fit, and the decorated method keeps its parameter and return types.

Decorator factories

To pass options to a decorator, write a function that returns a decorator:

TS retry.ts
function retry(times: number) {
  return function <This, Args extends unknown[], R>(
    target: (this: This, ...args: Args) => R,
    _context: ClassMethodDecoratorContext,
  ) {
    return function (this: This, ...args: Args): R {
      for (let attempt = 1; ; attempt++) {
        try {
          return target.apply(this, args);
        } catch (err) {
          if (attempt >= times) throw err;
          console.log(`attempt ${attempt} failed, retrying`);
        }
      }
    };
  };
}

let calls = 0;
class Api {
  @retry(3)
  fetchFlaky() {
    calls++;
    if (calls < 3) throw new Error("network");
    return "data";
  }
}

console.log(new Api().fetchFlaky());
Output
attempt 1 failed, retrying
attempt 2 failed, retrying
data

Class decorators

A class decorator receives the class itself. A common use is registering classes in a lookup table:

TS register.ts
const registry = new Map<string, new () => object>();

function register(name: string) {
  return function (target: new () => object, _context: ClassDecoratorContext) {
    registry.set(name, target);
  };
}

@register("csv")
class CsvExporter {}

@register("json")
class JsonExporter {}

console.log([...registry.keys()]);
Output
[ 'csv', 'json' ]

Field decorators and addInitializer

Field decorators can transform a field's initial value, and any decorator can schedule code to run when an instance is created via context.addInitializer:

TS field.ts
function uppercase(_target: undefined, _context: ClassFieldDecoratorContext) {
  return (initial: string) => initial.toUpperCase();
}

function bound<This, T extends (this: This, ...args: any[]) => any>(
  _target: T,
  context: ClassMethodDecoratorContext<This, T>,
) {
  context.addInitializer(function (this: This) {
    const self = this as Record<string | symbol, Function>;
    self[context.name] = self[context.name].bind(this);
  });
}

class Badge {
  @uppercase label = "beta";

  @bound
  describe() {
    return `badge: ${this.label}`;
  }
}

const { describe } = new Badge();
console.log(describe());
Output
badge: BETA

Without @bound, pulling describe off the object would lose this, and the call would crash.

Standard vs "experimental" decorators

For years, TypeScript supported an older, different decorator design behind the experimentalDecorators compiler flag. Angular, NestJS, TypeORM and MobX (in older versions) were built on it, and you'll see it in many codebases. The two systems aren't compatible:

Standard (TS 5.0+)Legacy (experimentalDecorators)
Signature(value, context)(target, propertyKey, descriptor)
Parameter decoratorsNot supportedSupported (used by Angular/NestJS DI)
emitDecoratorMetadataNot supportedSupported
StatusOn track to become standard JavaScriptTypeScript-specific

Use whichever your framework expects. For new code without a framework requirement, use the standard ones — no compiler flag needed.

When to use decorators

Decorators are excellent for cross-cutting concerns that would otherwise be copy-pasted into many methods: logging, timing, caching, authorization checks, retries. They hide behaviour, though, so keep each decorator small, well-named and obvious in what it does.