ZetCode

TypeScript Decorators

last modified October 15, 2026

A decorator is a function that is applied to a class or a class member with the @ syntax. It can observe the decorated element, replace it, or add behavior to it, for example logging, caching, validation, or registration in a framework. Decorators keep such cross-cutting concerns out of the business logic: the code of a method stays the same, and the decorator wraps it.

TypeScript supports two kinds of decorators. Since TypeScript 5.0, the default are the standard ECMAScript decorators, which most of this tutorial covers. The older, experimental decorators are enabled with the experimentalDecorators option and are still used by Angular and NestJS; they are described at the end. The two kinds have different function signatures and cannot be mixed.

Class Decorators

A standard decorator receives two arguments: the decorated value and a context object with information about it. A class decorator receives the class itself.

class_decorator.ts
const registry = new Map<string, Function>();

function register<T extends abstract new (...args: any[]) => unknown>(
    value: T,
    context: ClassDecoratorContext<T>,
): void {
    const name = String(context.name);
    registry.set(name, value);
    console.log(`registered ${name} (${context.kind})`);
}

@register
class UserService {}

@register
class OrderService {}

console.log([...registry.keys()]);

The register decorator stores every decorated class in a map under its name. It runs once, when the class is defined, not when an instance is created. The context.kind property tells what kind of element is decorated, here "class". A class decorator can also return a new class, which then replaces the original one.

$ npx tsx class_decorator.ts
registered UserService (class)
registered OrderService (class)
[ 'UserService', 'OrderService' ]

Method Decorators

A method decorator receives the original method and can return a new function that replaces it.

method_decorator.ts
function logged<This, Args extends unknown[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
    const name = String(context.name);

    return function (this: This, ...args: Args): Return {
        console.log(`-> ${name}(${args.join(", ")})`);
        const result = target.call(this, ...args);
        console.log(`<- ${name} returned ${result}`);
        return result;
    };
}

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

    @logged
    multiply(a: number, b: number): number {
        return a * b;
    }
}

const calc = new Calculator();
calc.add(2, 3);
calc.multiply(4, 5);

The replacement function logs the arguments, calls the original method with target.call, so that this keeps pointing to the instance, and logs the result. The type parameters make the decorator work for methods with any parameters and return type, and the compiler checks that the replacement has the same signature as the original.

$ npx tsx method_decorator.ts
-> add(2, 3)
<- add returned 5
-> multiply(4, 5)
<- multiply returned 20

Decorator Factories

A decorator factory is a function that takes configuration and returns a decorator. It is applied with parentheses.

factory_decorator.ts
function retry(times: number) {
    return function <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 {
            for (let attempt = 1; ; attempt++) {
                try {
                    return target.call(this, ...args);
                } catch (e) {
                    console.log(`${String(context.name)} failed (attempt ${attempt})`);
                    if (attempt >= times) {
                        throw e;
                    }
                }
            }
        };
    };
}

class Connection {
    private failures = 2;

    @retry(3)
    connect(host: string): string {
        if (this.failures-- > 0) {
            throw new Error("timeout");
        }
        return `connected to ${host}`;
    }
}

console.log(new Connection().connect("db.local"));

The call retry(3) returns the actual decorator, which captures the times value. The connect method fails twice and succeeds on the third attempt, so the caller only sees the final result. With @retry(2), the error from the second attempt would be thrown to the caller.

$ npx tsx factory_decorator.ts
connect failed (attempt 1)
connect failed (attempt 2)
connected to db.local

Caching Results

Decorators are a natural fit for caching, because the cached method does not need to know about the cache.

memoize_decorator.ts
function memoize<This, Arg, Return>(
    target: (this: This, arg: Arg) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, arg: Arg) => Return>,
) {
    const cache = new Map<Arg, Return>();

    return function (this: This, arg: Arg): Return {
        if (cache.has(arg)) {
            console.log(`cache hit: ${String(context.name)}(${arg})`);
            return cache.get(arg) as Return;
        }

        const result = target.call(this, arg);
        cache.set(arg, result);
        return result;
    };
}

class Pricing {
    @memoize
    priceFor(productId: number): number {
        console.log(`computing price for ${productId}`);
        return productId * 9.99;
    }
}

const pricing = new Pricing();
console.log(pricing.priceFor(1));
console.log(pricing.priceFor(2));
console.log(pricing.priceFor(1));

The first call for each product computes the price; the repeated call for product 1 is answered from the cache. The cache is created once per decorated method, so it is shared by all instances of the class; a per-instance cache would be stored in a WeakMap keyed by this.

$ npx tsx memoize_decorator.ts
computing price for 1
9.99
computing price for 2
19.98
cache hit: priceFor(1)
9.99

Field Decorators

A field decorator receives undefined as its value, because fields do not exist until an instance is created. It can return a function that transforms the initial value of the field.

field_decorator.ts
function trimmed<This>(_value: undefined, context: ClassFieldDecoratorContext<This, string>) {
    return function (initial: string): string {
        console.log(`initializing ${String(context.name)}`);
        return initial.trim();
    };
}

class Product {
    @trimmed
    name = "  Wireless Keyboard  ";

    @trimmed
    sku = " KB-100 ";
}

const product = new Product();
console.log(`[${product.name}] [${product.sku}]`);

The returned function runs for every new instance and receives the initializer value. The decorator is typed with ClassFieldDecoratorContext<This, string>, so applying it to a number field would be a compile-time error.

$ npx tsx field_decorator.ts
initializing name
initializing sku
[Wireless Keyboard] [KB-100]

Accessor Decorators

The accessor keyword declares an auto-accessor: a field with a generated getter and setter. Decorating it allows code to run on every assignment.

accessor_decorator.ts
function min(limit: number) {
    return function <This>(
        target: ClassAccessorDecoratorTarget<This, number>,
        context: ClassAccessorDecoratorContext<This, number>,
    ): ClassAccessorDecoratorResult<This, number> {
        const check = (value: number): number => {
            if (value < limit) {
                throw new RangeError(`${String(context.name)} must be at least ${limit}, got ${value}`);
            }
            return value;
        };

        return {
            init: check,
            set(value) {
                target.set.call(this, check(value));
            },
        };
    };
}

class Account {
    @min(0)
    accessor balance = 100;
}

const account = new Account();
account.balance = 40;
console.log(account.balance);

try {
    account.balance = -5;
} catch (e) {
    console.log(e instanceof Error ? e.message : e);
}

console.log(account.balance);

The decorator returns an object with an init function, which validates the initial value, and a set function, which validates every assignment before calling the original setter. The invalid assignment throws, and the balance stays 40.

$ npx tsx accessor_decorator.ts
40
balance must be at least 0, got -5
40

Initializers

The addInitializer method of the context registers code that runs when an instance is created. A common use is binding methods to their instance.

bound_decorator.ts
function bound<This extends object, Value extends (this: This, ...args: any) => any>(
    _target: Value,
    context: ClassMethodDecoratorContext<This, Value>,
): void {
    context.addInitializer(function (this: This) {
        const self = this as Record<PropertyKey, unknown>;
        const method = self[context.name] as Function;
        self[context.name] = method.bind(this);
    });
}

class Counter {
    count = 0;

    @bound
    increment(): number {
        return ++this.count;
    }
}

const counter = new Counter();
const { increment } = counter;

increment();
increment();
console.log(counter.count);

Without the decorator, calling the extracted increment function would fail, because this would be undefined. The initializer runs for each new Counter and stores a bound copy of the method on the instance, so the method can be passed around as a callback, for example to an event handler.

$ npx tsx bound_decorator.ts
2

Order of Evaluation

When several decorators are applied to one element, their expressions are evaluated from top to bottom, but the decorators are applied from bottom to top.

multiple_decorators.ts
function trace(label: string) {
    console.log(`evaluate ${label}`);

    return function (_value: unknown, context: DecoratorContext): void {
        console.log(`apply ${label} to ${String(context.name)}`);
    };
}

class Example {
    @trace("first")
    @trace("second")
    method(): void {}
}

console.log("class defined");

Both factory calls run first, in source order. Then the decorators are applied in reverse order, so the decorator closest to the method wraps it first. When decorators wrap a method, the topmost one therefore becomes the outermost layer. All of this happens before the next statement runs, when the class is defined.

$ npx tsx multiple_decorators.ts
evaluate first
evaluate second
apply second to method
apply first to method
class defined

Legacy Decorators

Code written for Angular, NestJS, TypeORM, and other frameworks uses the older decorators. They are enabled in tsconfig.json.

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

The experimentalDecorators option switches all decorators in the project to the legacy behavior. The emitDecoratorMetadata option is required by frameworks that read type information at runtime, for dependency injection. Legacy decorators receive different arguments: a method decorator gets the prototype, the method name, and a property descriptor.

legacy_method.ts
function LogMethod(target: object, key: string, descriptor: PropertyDescriptor): void {
    console.log(`decorating ${key}`);
    const original = descriptor.value;

    descriptor.value = function (this: unknown, ...args: unknown[]) {
        console.log(`calling ${key}(${args.join(", ")})`);
        return original.apply(this, args);
    };
}

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

console.log("class defined");
const calc = new Calculator();
console.log(calc.add(2, 3));

The output shows that the decorator itself runs when the class is defined, before any instance exists. To act on calls, it replaces descriptor.value with a wrapper function. This example compiles only with experimentalDecorators enabled.

$ npx tsx legacy_method.ts
decorating add
class defined
calling add(2, 3)
5

Legacy Parameter Decorators

Parameter decorators exist only in the legacy mode. They receive the prototype, the method name, and the index of the parameter.

parameter_decorator.ts
const required: Record<string, number[]> = {};

function Required(target: object, key: string, index: number): void {
    (required[key] ??= []).push(index);
    console.log(`parameter ${index} of ${key} is required`);
}

class Greeter {
    greet(@Required name: string, greeting?: string): string {
        return `${greeting ?? "Hello"}, ${name}!`;
    }
}

console.log(required);
console.log(new Greeter().greet("Alice"));

The decorator only records that the first parameter of greet is required; it cannot change the parameter. Frameworks combine such records with a method decorator that checks the arguments, or use them for dependency injection. The standard decorators do not support parameters.

$ npx tsx parameter_decorator.ts
parameter 0 of greet is required
{ greet: [ 0 ] }
Hello, Alice!

Best Practices

Source

TypeScript 5.0 release notes: Decorators

This tutorial covered decorators in TypeScript. Standard decorators are the default since TypeScript 5.0; the legacy variant remains in use by frameworks that were built on it.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all TypeScript tutorials.