TypeScript Decorators: Class, Method, Property and Parameter Decorators, Factories and reflect-metadata

Key takeaways

TypeScript decorators: experimentalDecorators, class/method/property decorators, decorator factories, logging, validation, authorization, and caching patterns.

Introduction

What are decorators?

A decorator is a special declaration that adds metadata to classes, methods, properties, and more—or wraps their behavior. Syntactically it is nothing more than a function prefixed with @ and placed directly above a class, method, accessor, property, or parameter. Under the hood, though, a decorator is a small piece of metaprogramming: it lets you intercept a declaration at definition time and either annotate it or replace it with a modified version, without touching the code that eventually calls it.

Why decorators exist

Most decorator use cases boil down to cross-cutting concerns—behavior that logically belongs to many different classes and methods at once, but which has nothing to do with the actual business logic of any single one of them. Logging, timing, input validation, authorization checks, caching, retry logic, and dependency injection are the textbook examples. Without decorators, you end up writing the same boilerplate inside every method: log the call, validate the argument, check the caller’s role, then finally do the real work. That boilerplate obscures the actual intent of the method and has to be copy-pasted (and kept in sync) across the codebase.

Decorators solve this by letting you factor the cross-cutting behavior out into a single reusable function and attach it declaratively:

class UserService {
    @log("USER")
    @authorize(["admin"])
    deleteUser(id: string) {
        // Only the actual business logic lives here.
        // Logging and authorization are handled separately.
    }
}

Reading deleteUser now tells you exactly what it does—delete a user—while the @log and @authorize annotations tell you, at a glance, what infrastructure wraps around it. This is the same idea behind Python decorators, Java annotations processed by Spring/Aspect-Oriented Programming, and C# attributes; TypeScript decorators bring the same pattern to JavaScript’s class syntax. It is also precisely why frameworks that lean heavily on dependency injection and declarative configuration—Angular, NestJS, TypeORM, class-validator—all adopted decorators as their primary extension mechanism rather than plain functions or config objects.

Legacy decorators vs. the TC39 Stage 3 proposal

One detail trips up almost everyone who searches for TypeScript decorator tutorials: there are two, incompatible decorator implementations, and most existing tutorials (including the examples below, historically) were written against the older one.

  • Legacy decorators (experimentalDecorators: true) were TypeScript’s own pre-standard implementation, based on an early, now-abandoned TC39 proposal. This is what Angular has used since v2 and what most existing NestJS code still targets. Decorator functions receive raw arguments like target, propertyKey, and a PropertyDescriptor.
  • TC39 Stage 3 decorators are the version that was actually standardized and shipped without a compiler flag starting in TypeScript 5.0. Their runtime signature is different (a context object replaces the loose positional arguments), they cannot mutate a class’s shape the way legacy property decorators could, and they compile to different, spec-compliant JavaScript.

This matters in practice because you cannot mix the two: a project targeting the new standard decorators cannot consume a library’s legacy-style decorator implementation as-is, and vice versa. The examples in this article use the legacy model (experimentalDecorators: true) because it remains the de facto standard for NestJS, TypeORM, and Angular projects as of this writing—but when you start a new project on TypeScript 5+, check which model any decorator-heavy library expects before wiring tsconfig.json.


Enabling decorators in tsconfig.json

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

experimentalDecorators turns on the legacy decorator syntax and runtime described above—without it, the compiler treats @ as a syntax error outside of the new Stage 3 form. emitDecoratorMetadata is a separate, optional flag: it tells the compiler to also emit type metadata (parameter types, return types, property types) alongside each decorated declaration, readable at runtime through the reflect-metadata package. You only need emitDecoratorMetadata if a decorator actually inspects that type information—NestJS’s dependency injection and class-validator’s type-aware validators both depend on it, but a simple logging decorator does not.


Class decorators

Basic usage

A class decorator receives the constructor function itself as its only argument. It runs once, at class definition time—not when an instance is created—so it is the right tool for anything that needs to inspect or modify the class as a whole rather than a particular call.

function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}
@sealed
class User {
    name: string;
    
    constructor(name: string) {
        this.name = name;
    }
}

Object.seal prevents new properties from being added to the constructor or its prototype after the fact. This is a common defensive pattern in library code: it stops consumers from monkey-patching a class’s shape at runtime while still allowing existing properties to be reassigned.

A logging class decorator

A class decorator can also replace the class entirely by returning a new constructor that extends the original—this is how you inject behavior into every instantiation without editing the original constructor body.

function logger<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        constructor(...args: any[]) {
            super(...args);
            console.log(`${constructor.name} instance created`);
        }
    };
}
@logger
class User {
    constructor(public name: string) {}
}
const user = new User("Alice");
// Output: User instance created

Notice the generic constraint T extends { new(...args: any[]): {} }—it tells the compiler “this only works on constructible classes,” which is what lets class extends constructor type-check. The returned anonymous class calls super(...args) to preserve the original constructor’s behavior, then adds the logging side effect. Because @logger replaces the binding that User refers to, every new User(...) call anywhere in the codebase picks up the logging automatically—no call site needs to know the decorator exists.


Method decorators

Basic usage

A method decorator is where most real-world decorator usage happens, because it lets you wrap a single method’s behavior—the most common shape a cross-cutting concern takes. It receives three arguments: the prototype (target), the method name (propertyKey), and the method’s PropertyDescriptor, which holds the actual function under descriptor.value.

function log(
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
) {
    const originalMethod = descriptor.value;
    
    descriptor.value = function(...args: any[]) {
        console.log(`${propertyKey} called:`, args);
        const result = originalMethod.apply(this, args);
        console.log(`${propertyKey} result:`, result);
        return result;
    };
    
    return descriptor;
}
class Calculator {
    @log
    add(a: number, b: number): number {
        return a + b;
    }
}
const calc = new Calculator();
calc.add(10, 20);
// Output:
// add called: [10, 20]
// add result: 30

The core pattern here—save originalMethod, replace descriptor.value with a wrapper, call the original via .apply(this, args) inside the wrapper—is the template almost every method decorator follows. The .apply(this, args) call is not optional: it is what preserves the correct this binding and forwards the exact arguments the caller passed, so the wrapped method behaves identically to the original except for the added side effect.

A timing method decorator

The same wrapping pattern works for async methods; you just need the wrapper itself to be async so it can await the original before measuring elapsed time.

function measure(
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
) {
    const originalMethod = descriptor.value;
    
    descriptor.value = async function(...args: any[]) {
        const start = performance.now();
        const result = await originalMethod.apply(this, args);
        const end = performance.now();
        console.log(`${propertyKey} took ${(end - start).toFixed(2)}ms`);
        return result;
    };
    
    return descriptor;
}
class DataService {
    @measure
    async fetchData() {
        await new Promise(resolve => setTimeout(resolve, 1000));
        return { data: "result" };
    }
}
const service = new DataService();
await service.fetchData();
// Output: fetchData took 1001.23ms

A subtle pitfall here: if you forget await before originalMethod.apply(...), result becomes the unresolved Promise itself, and performance.now() measures almost nothing (the time to schedule the async call, not to complete it) while the caller is still left correctly awaiting the outer wrapper—so the bug is easy to miss until you actually check the logged timing value against your expectations.


Decorator execution order

A question every developer hits eventually: when a class has decorators on the class itself, on multiple methods, and on parameters, in what order do they actually run? TypeScript’s legacy decorators follow a consistent, well-defined “onion” evaluation order: decorators closer to the declaration run first for evaluation, but wrapping is applied from the inside out, and across categories the order is fixed—parameter decorators, then method/accessor/property decorators, then class decorators, with instance members evaluated before static members, and members evaluated in the order they appear in the source top-to-bottom.

sequenceDiagram
    participant Src as Source order
    participant TS as TypeScript compiler
    participant RT as Runtime

    Src->>TS: 1. Parameter decorators\n(per method, in declaration order)
    Src->>TS: 2. Method/accessor/property decorators\n(instance members first, then static)
    Src->>TS: 3. Class decorator\n(runs last, once)
    TS->>RT: Decorator factories evaluated top-to-bottom,\nfactory results applied bottom-to-top
    RT->>RT: Class becomes fully defined

For decorator factories stacked on the same declaration, TypeScript evaluates the factory calls themselves top to bottom, but applies the resulting decorator functions bottom to top:

class UserService {
    @first()   // factory call order: 1st
    @second()  // factory call order: 2nd
    method() {}
    // application order: @second wraps method() first, then @first wraps that result
}

This “factories run top-down, decorators apply bottom-up” rule is the single most common source of decorator-ordering bugs. If @authorize and @log are stacked and you get the order wrong, you can end up logging calls that never actually happened because authorization rejected them first, or—worse—running privileged logic before the authorization check has had a chance to reject it. When stacking decorators that both gate and observe behavior, always trace through which one ends up as the outermost wrapper, since that is the one that runs first and last around every call.


Property decorators

Basic usage

A property decorator receives only target and propertyKey—there is no PropertyDescriptor argument, and in the legacy model a property decorator’s return value is ignored. This means a property decorator cannot wrap a value the way a method decorator wraps a function; instead, it typically uses Object.defineProperty to install a getter/setter pair that intercepts reads and writes going forward.

function readonly(target: any, propertyKey: string) {
    Object.defineProperty(target, propertyKey, {
        writable: false
    });
}
class User {
    @readonly
    id: string = "U001";
    
    name: string = "Alice";
}
const user = new User();
console.log(user.id);  // U001
// user.id = "U002";   // Error (strict mode)

A validating property decorator

Combining a property decorator with a closure-captured backing variable is how you implement field-level validation without touching every setter by hand.

function validate(validationFn: (value: any) => boolean) {
    return function(target: any, propertyKey: string) {
        let value: any;
        
        Object.defineProperty(target, propertyKey, {
            get() {
                return value;
            },
            set(newValue: any) {
                if (!validationFn(newValue)) {
                    throw new Error(`${propertyKey} validation failed`);
                }
                value = newValue;
            }
        });
    };
}
class User {
    @validate((value) => value.length >= 2)
    name!: string;
    
    @validate((value) => value >= 0 && value <= 150)
    age!: number;
}
const user = new User();
user.name = "Alice";  // OK
user.age = 25;        // OK
// user.name = "a";   // Throws
// user.age = 200;    // Throws

Notice the closure variable value—because Object.defineProperty is called once per decorator application on the prototype, but the getter/setter pair captures its own value variable per call to the outer validate() factory, each property gets an independent backing store despite sharing the same decorator function. This is a common point of confusion: the backing storage lives in the decorator’s closure, not as a visible field on the instance, which is why console.log(user) will not show name or age directly as own enumerable properties in the way a plain class field would.


Parameter decorators

Basic usage

A parameter decorator receives the prototype, the method name, and the index of the parameter it decorates. On its own it cannot change how the method behaves—there is no descriptor to rewrite—so parameter decorators are almost always used to record metadata (typically via reflect-metadata) that a corresponding method or class decorator later reads back to decide what to validate or inject.

function required(
    target: any,
    propertyKey: string,
    parameterIndex: number
) {
    console.log(`Parameter ${parameterIndex} of ${propertyKey} is required`);
}
class User {
    greet(@required name: string) {
        console.log(`Hello, ${name}!`);
    }
}

By itself, @required only logs a message at class-definition time—it does not enforce anything. In a real validation framework, @required would instead push parameterIndex into a metadata array keyed by propertyKey, and a method decorator on greet (or a framework-level proxy) would read that array back at call time to check whether the corresponding argument is undefined. This two-step “record, then read” pattern—parameter decorators write, method/class decorators read—is exactly how NestJS’s @Body(), @Param(), and @Query() decorators work under the hood: each just records which parameter should receive which part of the incoming HTTP request, and the framework’s own runtime does the actual extraction and injection when a request arrives.


reflect-metadata and why frameworks depend on it

reflect-metadata is a polyfill for a metadata reflection API that was originally proposed alongside decorators. It gives you Reflect.defineMetadata(key, value, target) and Reflect.getMetadata(key, target)—effectively a way to attach an arbitrary, keyed piece of data to a class, method, or property that is invisible to normal code but readable by anything that knows to ask for it.

import "reflect-metadata";

function Injectable() {
    return function (target: Function) {
        // no-op marker; presence of the metadata key is what matters
        Reflect.defineMetadata("injectable", true, target);
    };
}

class UserRepository {}

@Injectable()
class UserService {
    constructor(private repo: UserRepository) {}
}

// A DI container can now ask:
console.log(Reflect.getMetadata("injectable", UserService)); // true

// With emitDecoratorMetadata enabled, the compiler also stores the
// constructor parameter TYPES automatically:
const paramTypes = Reflect.getMetadata("design:paramtypes", UserService);
console.log(paramTypes); // [UserRepository]

This is the mechanism NestJS’s dependency injection container is built on. When you write constructor(private repo: UserRepository) in a NestJS provider, you never manually register that UserService needs a UserRepository—the compiler, via emitDecoratorMetadata, silently attaches the parameter types as metadata, and NestJS’s container reads design:paramtypes back at bootstrap time to resolve and inject the right instances. This is also why NestJS DI famously breaks for parameters typed only as an interface: interfaces do not exist at runtime, so design:paramtypes records Object instead of anything useful, and the container has nothing to resolve against unless you supply an explicit injection token.

Practically, this means: if you are debugging why a NestJS provider is not receiving the dependency you expect, checking whether emitDecoratorMetadata is enabled and whether the constructor parameter is a concrete class (not just an interface or a generic) is often the fastest path to the actual bug.


Decorator factories

Concept

A plain decorator and a decorator factory look similar at the call site (@log vs. @log("USER")) but are fundamentally different functions. A plain decorator is the decorator function—TypeScript calls it directly with (target, propertyKey, descriptor). A decorator factory is a function that returns a decorator function, which is what lets you pass configuration:

function log(prefix: string) {
    return function(
        target: any,
        propertyKey: string,
        descriptor: PropertyDescriptor
    ) {
        const originalMethod = descriptor.value;
        
        descriptor.value = function(...args: any[]) {
            console.log(`[${prefix}] ${propertyKey} called`);
            return originalMethod.apply(this, args);
        };
        
        return descriptor;
    };
}
class UserService {
    @log("USER")
    createUser(name: string) {
        console.log(`User created: ${name}`);
    }
    
    @log("AUTH")
    login(email: string) {
        console.log(`Login: ${email}`);
    }
}
const service = new UserService();
service.createUser("Alice");
// Output:
// [USER] createUser called
// User created: Alice
service.login("[email protected]");
// Output:
// [AUTH] login called
// Login: [email protected]

A common pitfall: forgetting the factory call

Because plain decorators and factories look almost identical, a frequent mistake is writing @log when the function is actually a factory (expects to be called with () first) or writing @log() when the function is actually a plain decorator. TypeScript’s type checker usually catches this at compile time with a confusing error about argument counts, but it is worth internalizing the rule directly: if the decorator needs configuration, it must be a factory, and factories must always be invoked with () at the call site—even with no arguments, e.g. @log() rather than @log, if log is defined as function log() { return function(...) {...} }.


Authorization and caching decorators

A role-check decorator

Authorization is one of the clearest illustrations of why decorators exist: the check itself (“does this user have the right role?”) is completely orthogonal to what the method actually does, and repeating an if (!hasRole(...)) throw ... at the top of every privileged method is exactly the kind of duplication decorators are meant to eliminate.

function authorize(roles: string[]) {
    return function(
        target: any,
        propertyKey: string,
        descriptor: PropertyDescriptor
    ) {
        const originalMethod = descriptor.value;
        
        descriptor.value = function(...args: any[]) {
            const userRole = getCurrentUserRole();  // e.g. from session
            
            if (!roles.includes(userRole)) {
                throw new Error("Forbidden");
            }
            
            return originalMethod.apply(this, args);
        };
        
        return descriptor;
    };
}
function getCurrentUserRole(): string {
    return "admin";  // In real apps, read from session/JWT
}
class AdminService {
    @authorize(["admin"])
    deleteUser(id: string) {
        console.log(`User deleted: ${id}`);
    }
    
    @authorize(["admin", "moderator"])
    banUser(id: string) {
        console.log(`User banned: ${id}`);
    }
}
const service = new AdminService();
service.deleteUser("U001");  // Succeeds as admin

A pitfall worth calling out explicitly: inside descriptor.value = function(...args) {...}, this refers to the instance the method was called on, only because it is a regular function expression, not an arrow function. If you rewrite the wrapper as an arrow function (descriptor.value = (...args) => {...}), this gets lexically captured from the surrounding scope at decoration time instead of the call site, and originalMethod.apply(this, args) silently breaks—this inside the wrapper will not be the AdminService instance at all. This is one of the most common bugs when developers “simplify” a method decorator by converting it to arrow-function syntax out of habit.

A caching decorator with a TTL

Caching decorators demonstrate a second layer worth understanding: the decorator factory’s outer scope (cacheStore here) is created once, when the decorator is applied to the class, and is then shared by every call to the decorated method across every instance of the class—unless you deliberately key the cache per-instance.

function cache(ttl: number = 60000) {
    const cacheStore = new Map<string, { value: any; expiry: number }>();
    
    return function(
        target: any,
        propertyKey: string,
        descriptor: PropertyDescriptor
    ) {
        const originalMethod = descriptor.value;
        
        descriptor.value = async function(...args: any[]) {
            const key = `${propertyKey}_${JSON.stringify(args)}`;
            const cached = cacheStore.get(key);
            
            if (cached && Date.now() < cached.expiry) {
                console.log("Returned from cache");
                return cached.value;
            }
            
            console.log("Computing fresh value");
            const result = await originalMethod.apply(this, args);
            cacheStore.set(key, { value: result, expiry: Date.now() + ttl });
            return result;
        };
        
        return descriptor;
    };
}
class DataService {
    @cache(5000)  // 5 second TTL
    async fetchUser(id: string) {
        await new Promise(resolve => setTimeout(resolve, 1000));
        return { id, name: "Alice" };
    }
}
const service = new DataService();
await service.fetchUser("U001");  // Computing fresh value
await service.fetchUser("U001");  // Returned from cache

Because cacheStore lives in the factory’s closure rather than on the instance, two separate DataService instances share the same cache—calling fetchUser("U001") on one instance will return a cached hit even if it was originally computed by a different instance. That is fine for something like a stateless read-through cache, but it is a real bug if you expected per-instance isolation (for example, per-request caching in a server that creates one service instance per HTTP request). If you need per-instance caching, key the Map by both the instance (e.g., a WeakMap<object, Map<...>>) and the argument signature, not just the argument signature alone.


Legacy vs Stage 3, arrow functions, and other decorator traps

  • Mixing legacy and TC39 Stage 3 decorators. They have different runtime signatures and are not interchangeable; check which model a library targets before enabling either flag.
  • Arrow functions inside a decorator’s replacement value. They break this binding for the wrapped method; always use a function expression when the wrapper needs to call originalMethod.apply(this, args).
  • Confusing a plain decorator with a factory. A factory must be invoked with (), even with no arguments; forgetting this produces a compile error, but the message is not always obvious about the real cause.
  • Assuming decorator application order matches source order. Evaluation of stacked decorator factories is top-down, but the resulting wrapping is applied bottom-up—get this backwards and security or logging decorators can end up firing in the wrong sequence.
  • Shared closures across instances. State captured in a decorator factory’s outer scope (like a cache Map) is shared by every instance unless you deliberately scope it per-instance.
  • Relying on design:paramtypes for interface-typed parameters. Interfaces disappear at compile time, so dependency-injection metadata for an interface-typed constructor parameter is useless without an explicit injection token.

Next in the series



Frequently Asked Questions (FAQ)

Q. In what order do stacked decorators run?

A. Decorator factories are evaluated from top to bottom, but the returned decorators are applied from bottom to top, so the decorator closest to the method wraps it first. With @A() @B() method(), A() and B() are called in that order, then B’s decorator runs, then A’s. This matters when one decorator relies on another, for example when an authorization check must wrap a logging decorator rather than the other way around.