Polyglot
Languages TypeScript decorators
TypeScript § decorators

Decorators

Decorators admit attaching metadata or behaviour to classes, methods, properties, accessors, and parameters. TypeScript supports two principal decorator forms: the Stage 3 ECMAScript decorators (the modern form, available since TS 5.0 and standardised in upcoming ECMAScript), and the older experimental decorators (under experimentalDecorators: true). The two forms have different semantics; the contemporary recommendation is the Stage 3 form for new code. The conventional uses are framework-driven: dependency injection (NestJS, Angular), API routing (NestJS), validation (class-validator), ORM (TypeORM, MikroORM), serialisation (class-transformer), and observability tooling.

Stage 3 decorators (TS 5.0+)

A decorator is a function applied with the @ syntax:

function logged(value: any, context: ClassMethodDecoratorContext) {
    const original = value;
    return function (this: any, ...args: any[]) {
        console.log(`Calling ${String(context.name)}`);
        const result = original.call(this, ...args);
        console.log(`Result: ${result}`);
        return result;
    };
}

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

new Calculator().add(2, 3);
// "Calling add"
// "Result: 5"

The decorator receives:

  • The decorated value — the method, class, accessor, etc.
  • A context object — including kind, name, static, private, addInitializer.

The decorator may return:

  • Nothing — the original is preserved.
  • A replacement — the original is replaced.

Decorator targets

The Stage 3 decorators apply to several positions:

PositionDecorator typeContext kind
ClassClassDecorator"class"
MethodClassMethodDecorator"method"
GetterClassGetterDecorator"getter"
SetterClassSetterDecorator"setter"
FieldClassFieldDecorator"field"
Auto-accessorClassAccessorDecorator"accessor"
function classDeco(target: any, context: ClassDecoratorContext) { /* ... */ }
function methodDeco(target: any, context: ClassMethodDecoratorContext) { /* ... */ }
function fieldDeco(target: any, context: ClassFieldDecoratorContext) { /* ... */ }

@classDeco
class Foo {
    @fieldDeco
    name: string = "";

    @methodDeco
    method() {}
}

The Stage 3 form does not admit decorators on parameters; for parameter decorators, the legacy experimental decorators are required.

Decorator factories

A decorator factory is a function that returns a decorator — admitting parameterisation:

function logged(prefix: string) {
    return function (value: any, context: ClassMethodDecoratorContext) {
        const original = value;
        return function (this: any, ...args: any[]) {
            console.log(`[${prefix}] Calling ${String(context.name)}`);
            return original.call(this, ...args);
        };
    };
}

class Service {
    @logged("INFO")
    process(data: string): string {
        return data.toUpperCase();
    }
}

The factory pattern is conventional for any decorator that takes configuration.

Class decorators

A class decorator transforms a class:

function sealed(target: any, context: ClassDecoratorContext) {
    Object.seal(target);
    Object.seal(target.prototype);
}

@sealed
class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
}

A class decorator may return a new class, replacing the original:

function withTimestamp<T extends new (...args: any[]) => any>(
    target: T,
    context: ClassDecoratorContext,
): T {
    return class extends target {
        timestamp = Date.now();
    } as T;
}

@withTimestamp
class Event {
    constructor(public name: string) {}
}

const e = new Event("click");
console.log((e as any).timestamp);

Method decorators

function memoize(value: any, context: ClassMethodDecoratorContext) {
    const cache = new Map<string, any>();
    return function (this: any, ...args: any[]) {
        const key = JSON.stringify(args);
        if (!cache.has(key)) {
            cache.set(key, value.call(this, ...args));
        }
        return cache.get(key);
    };
}

class Calculator {
    @memoize
    expensiveCalc(n: number): number {
        console.log(`Computing for ${n}`);
        return n * n;
    }
}

const c = new Calculator();
c.expensiveCalc(5);                              // computes
c.expensiveCalc(5);                              // cached

Field decorators

A field decorator may return an initialiser function:

function defaulted<T>(defaultValue: T) {
    return function (value: any, context: ClassFieldDecoratorContext) {
        return function (initial: T) {
            return initial === undefined ? defaultValue : initial;
        };
    };
}

class Config {
    @defaulted(8080)
    port: number;

    @defaulted("localhost")
    host: string;
}

Accessor decorators

The accessor keyword (TS 4.9+) introduces an auto-accessor — a property with auto-generated getter and setter:

class Counter {
    accessor value: number = 0;
    // Equivalent to:
    // private _value = 0;
    // get value() { return this._value; }
    // set value(v) { this._value = v; }
}

A decorator on an auto-accessor receives { get, set } and may transform either:

function tracked(value: { get: () => any; set: (v: any) => void }, context: ClassAccessorDecoratorContext) {
    return {
        get() {
            console.log(`Reading ${String(context.name)}`);
            return value.get.call(this);
        },
        set(newValue: any) {
            console.log(`Writing ${String(context.name)}: ${newValue}`);
            value.set.call(this, newValue);
        },
    };
}

class Counter {
    @tracked
    accessor value = 0;
}

const c = new Counter();
c.value = 5;                                     // "Writing value: 5"
c.value;                                          // "Reading value"

Initializers

The addInitializer method on the decorator context admits running code at class construction:

function autoBind(value: any, context: ClassMethodDecoratorContext) {
    context.addInitializer(function (this: any) {
        this[context.name] = value.bind(this);
    });
}

class Handler {
    name = "handler";

    @autoBind
    handle() {
        console.log(this.name);
    }
}

const h = new Handler();
const fn = h.handle;
fn();                                             // "handler" (this is bound)

The pattern admits substantial initialisation behaviour at class-construction time.

Legacy experimental decorators

The legacy decorator form (under experimentalDecorators: true) has different semantics; it is the form supported by older frameworks (Angular, NestJS, TypeORM) — most of which are migrating to the Stage 3 form.

// tsconfig.json:
// {
//     "compilerOptions": {
//         "experimentalDecorators": true,
//         "emitDecoratorMetadata": true
//     }
// }

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

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

The legacy form admits parameter decorators:

function inject(token: string) {
    return function (target: any, propertyKey: string | undefined, parameterIndex: number) {
        // store metadata about the injection
    };
}

class Service {
    constructor(@inject("logger") private logger: Logger) {}
}

Parameter decorators are not admitted in the Stage 3 form; the conventional defence is a class decorator that processes parameter information from Reflect.metadata (legacy) or constructor argument types directly (Stage 3).

Reflect.metadata (legacy)

Under emitDecoratorMetadata: true, the compiler emits type metadata accessible via Reflect.metadata — admitting runtime introspection of types:

import "reflect-metadata";

function logType(target: any, key: string) {
    const t = Reflect.getMetadata("design:type", target, key);
    console.log(`${key} type: ${t.name}`);
}

class Foo {
    @logType
    name: string = "";

    @logType
    age: number = 0;
}

The reflect-metadata polyfill is required; the mechanism is part of the legacy decorator infrastructure and not in the Stage 3 form. The conventional contemporary alternative is Stage 3 decorators with explicit metadata APIs.

Common patterns

Logging

function logged(value: any, context: ClassMethodDecoratorContext) {
    const original = value;
    return function (this: any, ...args: any[]) {
        console.log(`[${String(context.name)}]`, args);
        const result = original.call(this, ...args);
        console.log(`[${String(context.name)}] returned`, result);
        return result;
    };
}

class Service {
    @logged
    compute(x: number): number {
        return x * 2;
    }
}

Memoization

function memoize(value: any, context: ClassMethodDecoratorContext) {
    const cache = new WeakMap();
    return function (this: any, ...args: any[]) {
        const key = JSON.stringify(args);
        let instanceCache = cache.get(this);
        if (!instanceCache) {
            instanceCache = new Map();
            cache.set(this, instanceCache);
        }
        if (!instanceCache.has(key)) {
            instanceCache.set(key, value.call(this, ...args));
        }
        return instanceCache.get(key);
    };
}

Validation

function range(min: number, max: number) {
    return function (
        value: { get: () => any; set: (v: any) => void },
        context: ClassAccessorDecoratorContext,
    ) {
        return {
            get() { return value.get.call(this); },
            set(newValue: any) {
                if (typeof newValue !== "number" || newValue < min || newValue > max) {
                    throw new RangeError(`${String(context.name)} must be in [${min}, ${max}]`);
                }
                value.set.call(this, newValue);
            },
        };
    };
}

class Settings {
    @range(0, 100)
    accessor volume = 50;
}

const s = new Settings();
s.volume = 75;                                   // OK
// s.volume = 200;                                // RangeError

Time profiling

function timed(value: any, context: ClassMethodDecoratorContext) {
    return function (this: any, ...args: any[]) {
        const start = performance.now();
        const result = value.call(this, ...args);
        const elapsed = performance.now() - start;
        console.log(`${String(context.name)} took ${elapsed.toFixed(2)}ms`);
        return result;
    };
}

Async retry

function retry(attempts: number, delay = 100) {
    return function (value: any, context: ClassMethodDecoratorContext) {
        return async function (this: any, ...args: any[]) {
            let lastError: unknown;
            for (let i = 0; i < attempts; i++) {
                try {
                    return await value.apply(this, args);
                } catch (e) {
                    lastError = e;
                    if (i < attempts - 1) {
                        await new Promise(r => setTimeout(r, delay * (i + 1)));
                    }
                }
            }
            throw lastError;
        };
    };
}

class ApiClient {
    @retry(3, 1000)
    async fetchData(url: string): Promise<unknown> {
        const r = await fetch(url);
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
    }
}

Framework patterns

NestJS uses decorators substantially:

// NestJS example (uses experimental decorators):
@Controller("users")
class UserController {
    constructor(@Inject(UserService) private service: UserService) {}

    @Get()
    findAll(): Promise<User[]> {
        return this.service.findAll();
    }

    @Post()
    create(@Body() data: CreateUserDto): Promise<User> {
        return this.service.create(data);
    }
}

The pattern admits substantial declarative configuration; the framework processes the decorators at startup to register routes, dependencies, and middleware.

A note on the migration from legacy

For codebases using legacy experimental decorators:

  • Library compatibility — most major frameworks (Angular, NestJS, TypeORM) still use experimental decorators as of TS 5.7; migration plans exist.
  • Stage 3 in new code — for new projects without framework constraints, prefer Stage 3.
  • Parameter decorators — only legacy form admits them; Stage 3 does not.
  • Reflect metadata — legacy form; Stage 3 has no built-in equivalent.

The conventional contemporary advice depends on the framework: follow the framework’s recommendation. For framework-free code, Stage 3 is the conventional choice.

A note on the conventional discipline

The contemporary TypeScript decorator advice:

  • Use Stage 3 decorators for new code without framework constraints.
  • Use legacy experimental decorators when the framework requires them.
  • Use decorator factories for parameterisation.
  • Be cautious with metaprogramming — decorators introduce indirection that complicates debugging.
  • Document decorator behaviour — they are conventionally non-obvious.
  • Prefer plain functions for simple wrapping; use decorators when the syntax sugar is genuinely valuable.
  • Wait for ecosystem migration — many frameworks are still on the legacy form.

The combination — Stage 3 decorators on classes, methods, fields, accessors, with factories for parameterisation, and the legacy form for parameter decorators and framework integration — is the substance of TypeScript’s decorator surface. The discipline trades indirection for declarative power; the mechanism is most valuable in framework-driven contexts where the additional structure is amortised across substantial code.