Decorators add annotations and meta-programming to classes and class members using @expression syntax. You will see them in NestJS, Angular, TypeORM, and similar libraries.
The classic handbook page documents the older experimental (stage 2) decorator model. TypeScript 5.0+ also supports the newer stage 3 decorators. This lesson covers the experimental style (common in existing frameworks) and shows a modern TS 5 logging example at the end.
Official docs: Decorators · Decorators in TypeScript 5.0
Enable experimental decorators
Command line:
tsc --target ES5 --experimentalDecorators
This turns on the older decorator model while compiling. Many frameworks still expect this flag.
tsconfig.json:
{
"compilerOptions": {
"target": "ES2015",
"experimentalDecorators": true
}
}
Put the same setting in your project config so every tsc / build run enables decorators. Without it, @something on a class or method is a syntax error.
What is a decorator?
A decorator is a declaration attached to a class, method, accessor, property, or parameter. The expression must evaluate to a function called at runtime with information about the decorated declaration.
function sealed(target: Function) {
Object.seal(target);
Object.seal(target.prototype);
console.log(`Sealed class: ${target.name}`);
}
@sealed
class BugReport {
type = "report";
title: string;
constructor(t: string) {
this.title = t;
}
}
const report = new BugReport("Button is broken");
console.log(report.type); // "report"
console.log(report.title); // "Button is broken"
@sealed means: call sealed with the BugReport constructor when the class is defined (you should see Sealed class: BugReport once). Creating instances is unchanged: new BugReport("Button is broken") sets title, and you can read report.type and report.title like any normal object.
Decorator factories
To pass arguments, return the real decorator from a factory:
function color(value: string) {
return function (target: Function) {
console.log(`Class ${target.name} tagged with color ${value}`);
};
}
@color("blue")
class Example {
label = "demo";
}
const example = new Example();
console.log(example.label); // "demo"
@color("blue") first runs color("blue"), which returns the actual decorator. That logs Class Example tagged with color blue when the class is declared. Then new Example() still works: the factory only tagged the class; it did not change how you construct or use the instance.
Composition order
Multiple decorators stack like function composition. Factories run top-to-bottom; the decorator functions run bottom-to-top:
function first() {
console.log("first(): factory evaluated");
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
console.log("first(): called");
};
}
function second() {
console.log("second(): factory evaluated");
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
console.log("second(): called");
};
}
class ExampleClass {
@first()
@second()
method() {
console.log("method body");
}
}
const example = new ExampleClass();
example.method(); // "method body"
Both first() and second() are factories. Creating new ExampleClass() and calling example.method() still runs your method body. The decorator logs happen when the class is defined, before you ever create an instance.
Output when the class loads (then your method call):
first(): factory evaluated
second(): factory evaluated
second(): called
first(): called
method body
Read the log as: factories setup first, then the inner decorator (second) wraps the method, then the outer one (first) wraps that result. Knowing this order matters when two decorators both change the same member.
Class decorators
Applied to the constructor. Can observe, modify, or replace the class. Example that seals the constructor and prototype:
function sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@sealed
class BugReport {
type = "report";
title: string;
constructor(t: string) {
this.title = t;
}
}
const sealedReport = new BugReport("Crash on save");
console.log(sealedReport.title); // "Crash on save"
console.log(sealedReport.type); // "report"
Object.seal prevents adding or removing properties on the constructor and its prototype. The decorator runs once when the class is declared. You still construct normally with new BugReport(...) and use the fields on that instance.
Returning a subclass can inject new fields (types may not automatically see them):
function reportableClassDecorator<T extends { new (...args: any[]): {} }>(
constructor: T,
) {
return class extends constructor {
reportingURL = "http://www.example.com";
};
}
@reportableClassDecorator
class BugReport {
type = "report";
title: string;
constructor(t: string) {
this.title = t;
}
}
const bug = new BugReport("Needs dark mode");
console.log(bug.title); // "Needs dark mode"
console.log(bug.type); // "report"
console.log((bug as any).reportingURL); // "http://www.example.com"
The decorator returns a new class that extends the original and adds reportingURL. new BugReport("Needs dark mode") still works and prints title / type. At runtime bug also has reportingURL, but TypeScript’s static type may not list that field unless you model it carefully (hence the cast in the demo).
Method decorators
Receive the prototype (or constructor for statics), the member name, and a PropertyDescriptor. Example factory that sets enumerable and wraps the method:
function enumerable(value: boolean) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
descriptor.enumerable = value;
};
}
function log(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const original = descriptor.value as (...args: unknown[]) => unknown;
descriptor.value = function (...args: unknown[]) {
console.log(`Calling ${propertyKey}`);
return original.apply(this, args);
};
}
class Greeter {
greeting: string;
constructor(message: string) {
this.greeting = message;
}
@log
@enumerable(false)
greet() {
return "Hello, " + this.greeting;
}
}
const greeter = new Greeter("world");
console.log(greeter.greet());
// Calling greet
// "Hello, world"
console.log(greeter.greeting); // "world"
Create the instance with new Greeter("world"), then call greeter.greet(). The @log wrapper prints Calling greet before returning "Hello, world". @enumerable(false) changes the property descriptor so greet does not show up in for...in style enumeration. Method decorators are where you wrap the original function (logging, timing, auth checks) by replacing descriptor.value.
Accessor and property decorators
Accessors use the same three arguments as methods. Decorate only one of get / set for a member (they share one descriptor):
function configurable(value: boolean) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
descriptor.configurable = value;
};
}
class Point {
private _x: number;
private _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
@configurable(false)
get x() {
return this._x;
}
@configurable(false)
get y() {
return this._y;
}
}
const point = new Point(10, 20);
console.log(point.x); // 10
console.log(point.y); // 20
console.log(`Point at (${point.x}, ${point.y})`);
new Point(10, 20) stores the coordinates. Reading point.x and point.y uses the decorated getters and prints 10 and 20. @configurable(false) marks those accessors so they cannot be deleted or redefined later with Object.defineProperty. Because get and set share one descriptor, decorate one side of the pair in practice.
Property decorators receive only the target and property name (no descriptor). They are mainly for recording metadata (for example, marking a field for validation or dependency injection). They do not wrap a getter/setter the way method decorators can.
function format(target: object, propertyKey: string) {
console.log(`Decorated property: ${propertyKey}`);
}
class User {
@format
name: string = "";
}
const user = new User();
user.name = "Ada";
console.log(user.name); // "Ada"
When User is declared you see Decorated property: name. After that, usage is normal: new User(), assign user.name, read it back.
Parameter decorators
Observe constructor or method parameters (target, property key, parameter index). Often paired with a method decorator that reads metadata and validates at runtime. Frameworks use this pattern heavily (@Body(), @Inject(), etc.).
function inject(token: string) {
return function (
target: object,
propertyKey: string | undefined,
parameterIndex: number,
) {
console.log(
`Inject ${token} into param #${parameterIndex} of ${String(propertyKey)}`,
);
};
}
class OrdersService {
create(@inject("USER_ID") userId: string, amount: number) {
return { userId, amount };
}
}
const orders = new OrdersService();
console.log(orders.create("user-1", 99));
// { userId: "user-1", amount: 99 }
When the class loads, the parameter decorator logs which token maps to which argument index. Creating new OrdersService() and calling orders.create("user-1", 99) still works like a normal method. A parameter decorator does not replace the argument by itself; frameworks later read that metadata and supply values automatically.
Metadata (optional)
Some libraries use reflect-metadata plus emitDecoratorMetadata:
npm i reflect-metadata
Install the package that stores and reads runtime type metadata used by many DI frameworks.
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
emitDecoratorMetadata asks TypeScript to emit design-time type info for decorated declarations. Import reflect-metadata once at app startup. This is experimental and mostly relevant when a framework requires it.
Modern TypeScript 5 method decorator
Stage 3 style (no experimentalDecorators required for the new proposal) wraps a method and logs entry/exit:
function loggedMethod(headMessage = "LOG:") {
return function actualDecorator(
originalMethod: any,
context: ClassMethodDecoratorContext,
) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`${headMessage} Entering method '${methodName}'.`);
const result = originalMethod.call(this, ...args);
console.log(`${headMessage} Exiting method '${methodName}'.`);
return result;
}
return replacementMethod;
};
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod("⚠️")
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
p.greet();
// ⚠️ Entering method 'greet'.
// Hello, my name is Ray.
// ⚠️ Exiting method 'greet'.
const p2 = new Person("Ada");
p2.greet();
// ⚠️ Entering method 'greet'.
// Hello, my name is Ada.
// ⚠️ Exiting method 'greet'.
In the newer model the decorator receives the original method and a context object (with the method name and more). Returning replacementMethod swaps in the wrapper. Create people with new Person("Ray") / new Person("Ada"), then call greet() on each instance. Every call prints enter/exit logs around the real greeting. This is the style TypeScript 5+ prefers for new code that does not need the older experimental pipeline.
When to use decorators
- Use them when your framework expects them (NestJS controllers, Angular components, ORMs).
- Prefer plain functions for app logic you control : easier to test and follow.
- Know both models: experimental (
experimentalDecorators) vs TC39 stage 3 (TS 5+).
Practice
- Enable
experimentalDecoratorsintsconfig.json. - Write a
@logmethod decorator that prints the method name before calling the original. Create an instance and call the method twice. - Apply two decorator factories to one method, create an instance, call the method, and confirm the evaluation order in the console.