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.
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.
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.
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.
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.
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.
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.
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.
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.
{
"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.
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.
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
- Prefer Standard Decorators: Use them in new code; enable
experimentalDecoratorsonly for frameworks that require it. - Keep Decorators Focused: A decorator should add one concern, such as logging or caching, without changing what the method does.
- Type the Context: Use
ClassMethodDecoratorContextand related types so that a decorator can only be applied where it makes sense. - Preserve this and the Signature: Call the original with
target.call(this, ...args)and return its result. - Remember When Decorators Run: They run once, at class definition; per-call behavior belongs in the returned wrapper.
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
List all TypeScript tutorials.