DevToolBoxGRATIS
Blog

TypeScript Decorators: Complete Gids met Praktische Voorbeelden

15 minby DevToolBox

TL;DR

TypeScript decorators are special syntax (@expression) that attach to classes, methods, properties, or parameters to add metadata or modify behavior at design time. TypeScript 5.0 ships TC39 Stage 3 decorators natively (no config needed); older frameworks use legacy experimentalDecorators. Key patterns: @log wraps methods; @Injectable registers DI tokens; @Column maps ORM fields. Use our TypeScript converter to check your decorator output.

Key Takeaways

  • TC39 decorators (TS 5.0+) need no tsconfig flag; legacy decorators require experimentalDecorators: true
  • Class decorators can replace or extend a class; method decorators wrap function behavior
  • Decorator factories enable parameterized decorators: @retry(3, 500)
  • Stacked decorators evaluate top-to-bottom but execute bottom-to-top (function composition order)
  • reflect-metadata + emitDecoratorMetadata unlocks runtime type reflection for DI containers
  • NestJS, Angular, TypeORM all rely on legacy experimental decorators — migration to TC39 is ongoing
  • The new accessor keyword (TS 5.0) enables observable/reactive field patterns without manual getters

What Are TypeScript Decorators?

Decorators are a special kind of declaration that can be attached to a class, method, accessor, property, or parameter using the @expression syntax. The expression must evaluate to a function that is called at runtime with information about the decorated declaration. Decorators originated from Python's decorator pattern and Java's annotations, and were popularized in the TypeScript ecosystem by Angular and NestJS before becoming a formal ECMAScript proposal.

The core appeal of decorators is declarative, cross-cutting behavior: instead of manually wrapping every method call in a try/catch for logging, you attach @log once. Instead of manually registering every service in a DI container, you annotate the class with @Injectable(). This separation of concerns is the foundation of Aspect-Oriented Programming (AOP) in TypeScript.

TC39 Proposal: Stage 3 Status

The ECMAScript decorators proposal has had a long history. An early Stage 1 design was what TypeScript's legacy experimentalDecorators implemented. The proposal was substantially redesigned, and in 2022 it reached Stage 3 — meaning the API is finalized and implementations can begin. TypeScript 5.0 (released March 2023) was the first TypeScript version to implement TC39 Stage 3 decorators. The two systems are incompatible: you cannot mix them in the same file, and each has distinct APIs.

Enabling Decorators in tsconfig.json

// tsconfig.json — TC39 Standard Decorators (TypeScript 5.0+)
// No special flag is needed. Just target ES2022 or higher.
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "strict": true
  }
}

// tsconfig.json — Legacy Experimental Decorators
// Required by Angular, NestJS, TypeORM, MobX, class-validator
{
  "compilerOptions": {
    "target": "ES2022",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,   // enables runtime type reflection
    "strict": true
  }
}

// Install reflect-metadata polyfill for DI patterns (legacy only)
// npm install reflect-metadata
// Then add at your app entry point (e.g., main.ts):
import 'reflect-metadata';

Class Decorators

A class decorator is applied to the class constructor. In TC39 decorators, the signature is (target: Function, context: ClassDecoratorContext) => Function | void. You can return a new class (replacing the original) or modify the original in place. In legacy decorators, the signature is simply (constructor: Function) => void or a new constructor.

Adding Metadata (TC39)

// TC39 class decorator: attach metadata via context.metadata
function entity(tableName: string) {
  return function <T extends abstract new (...args: any[]) => {}>(
    target: T,
    context: ClassDecoratorContext
  ) {
    // context.metadata is a shared object for this class hierarchy
    context.metadata['tableName'] = tableName;
    context.metadata['entity'] = true;
    return target;
  };
}

function column(options: { type: string; nullable?: boolean }) {
  return function (_value: undefined, context: ClassFieldDecoratorContext) {
    context.metadata['columns'] ??= [];
    (context.metadata['columns'] as any[]).push({
      name: context.name,
      ...options,
    });
  };
}

@entity('users')
class User {
  @column({ type: 'varchar' })
  name: string = '';

  @column({ type: 'varchar', nullable: false })
  email: string = '';
}

// Access metadata at runtime
const meta = (User as any)[Symbol.metadata];
console.log(meta.tableName);  // 'users'
console.log(meta.columns);    // [{ name: 'name', type: 'varchar' }, ...]

Modifying Class Behavior: Sealed and Frozen

// Prevent modification of class and its prototype
function sealed<T extends abstract new (...args: any[]) => {}>(
  target: T,
  context: ClassDecoratorContext
) {
  Object.seal(target);
  Object.seal(target.prototype);
}

// Add automatic timestamp fields to any class
function withTimestamps<T extends new (...args: any[]) => {}>(
  target: T,
  context: ClassDecoratorContext
) {
  return class extends target {
    readonly createdAt: Date = new Date();
    updatedAt: Date = new Date();

    touch() {
      (this as any).updatedAt = new Date();
    }
  };
}

@sealed
@withTimestamps
class Product {
  constructor(
    public name: string,
    public price: number
  ) {}
}

const p = new Product('Widget', 9.99);
console.log((p as any).createdAt); // Date object
(p as any).touch();                 // updates updatedAt

Singleton Pattern with Class Decorator

// Singleton: ensure only one instance exists per class
function singleton<T extends new (...args: any[]) => {}>(
  target: T,
  context: ClassDecoratorContext
) {
  let instance: InstanceType<T> | undefined;

  return new Proxy(target, {
    construct(target, args) {
      if (!instance) {
        instance = Reflect.construct(target, args) as InstanceType<T>;
      }
      return instance!;
    },
  }) as T;
}

@singleton
class DatabaseConnection {
  readonly id = Math.random();
  constructor(public connectionString: string) {
    console.log('Creating new connection to', connectionString);
  }
  query(sql: string) { /* ... */ }
}

const db1 = new DatabaseConnection('postgres://localhost/mydb');
const db2 = new DatabaseConnection('postgres://localhost/other');
console.log(db1 === db2); // true — same instance
console.log(db1.id === db2.id); // true

Method Decorators

Method decorators intercept method calls. In TC39 decorators, a method decorator receives (target: Function, context: ClassMethodDecoratorContext) and returns a replacement function or void. The context object provides name, kind, static, private, and addInitializer. This is where decorators shine for cross-cutting concerns.

Logging and Timing

// Structured logging decorator (TC39)
function log(options: { level?: 'info' | 'warn' | 'error' } = {}) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    const methodName = String(context.name);
    const level = options.level ?? 'info';

    return function (this: any, ...args: any[]) {
      const start = performance.now();
      console[level](`[${methodName}] called with`, args);
      try {
        const result = target.call(this, ...args);
        // Handle promises transparently
        if (result instanceof Promise) {
          return result
            .then((val) => {
              const ms = (performance.now() - start).toFixed(2);
              console[level](`[${methodName}] resolved in ${ms}ms →`, val);
              return val;
            })
            .catch((err) => {
              console.error(`[${methodName}] rejected after`,
                (performance.now() - start).toFixed(2), 'ms:', err);
              throw err;
            });
        }
        const ms = (performance.now() - start).toFixed(2);
        console[level](`[${methodName}] returned in ${ms}ms →`, result);
        return result;
      } catch (err) {
        console.error(`[${methodName}] threw:`, err);
        throw err;
      }
    };
  };
}

// Standalone timing decorator
function timing(target: Function, context: ClassMethodDecoratorContext) {
  const name = String(context.name);
  return async function (this: any, ...args: any[]) {
    const start = performance.now();
    try {
      return await target.call(this, ...args);
    } finally {
      console.log(`${name}: ${(performance.now() - start).toFixed(2)}ms`);
    }
  };
}

class ReportService {
  @log({ level: 'info' })
  generateReport(userId: string, type: string) {
    return { userId, type, data: [] };
  }

  @timing
  async fetchFromDatabase(query: string): Promise<any[]> {
    return []; // simulate DB call
  }
}

Memoization

// Memoize caches method results keyed by serialized arguments
function memoize(
  target: Function,
  context: ClassMethodDecoratorContext
) {
  // Each instance gets its own cache via WeakMap
  const cacheMap = new WeakMap<object, Map<string, any>>();

  return function (this: any, ...args: any[]) {
    if (!cacheMap.has(this)) {
      cacheMap.set(this, new Map());
    }
    const cache = cacheMap.get(this)!;
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

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

// TTL (time-to-live) cache: expire results after N milliseconds
function cache(ttlMs: number) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    const store = new Map<string, { value: any; expiresAt: number }>();

    return function (this: any, ...args: any[]) {
      const key = JSON.stringify(args);
      const entry = store.get(key);
      if (entry && Date.now() < entry.expiresAt) {
        return entry.value;
      }
      const value = target.call(this, ...args);
      store.set(key, { value, expiresAt: Date.now() + ttlMs });
      return value;
    };
  };
}

class MathUtils {
  @memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }

  @cache(60_000) // cache results for 1 minute
  expensiveCalc(x: number, y: number): number {
    // Simulate heavy computation
    return x ** y;
  }
}

const mu = new MathUtils();
console.log(mu.fibonacci(40)); // fast due to memoization

Rate Limiting and Retry

// Rate limit: cap calls to N per windowMs
function rateLimit(maxCalls: number, windowMs: number) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    const calls: number[] = [];
    return function (this: any, ...args: any[]) {
      const now = Date.now();
      // Evict calls outside the sliding window
      while (calls.length && calls[0] < now - windowMs) calls.shift();
      if (calls.length >= maxCalls) {
        const waitMs = calls[0]! + windowMs - now;
        throw new Error(
          "Rate limit: max " + maxCalls + " calls/" + windowMs + "ms. " +
          "Retry in " + waitMs + "ms"
        );
      }
      calls.push(now);
      return target.call(this, ...args);
    };
  };
}

// Retry with exponential backoff
function retry(maxAttempts: number, baseDelayMs = 200) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    return async function (this: any, ...args: any[]) {
      let lastError: unknown;
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await target.call(this, ...args);
        } catch (err) {
          lastError = err;
          if (attempt < maxAttempts) {
            const delay = baseDelayMs * 2 ** (attempt - 1); // exponential
            console.warn(
              `Attempt ${attempt}/${maxAttempts} failed, retrying in ${delay}ms`
            );
            await new Promise((r) => setTimeout(r, delay));
          }
        }
      }
      throw lastError;
    };
  };
}

class ExternalApiService {
  @rateLimit(10, 60_000) // max 10 requests per minute
  @retry(3, 500)         // retry up to 3 times with backoff
  async callApi(endpoint: string): Promise<unknown> {
    const res = await fetch(endpoint);
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
    return res.json();
  }
}

Property Decorators

Property decorators in TC39 decorators are called field decorators. A field decorator has the signature (_value: undefined, context: ClassFieldDecoratorContext) => initializer | void. Returning a function acts as an initializer: it receives the initial value and returns the actual value to store. This enables validation, transformation, and default injection at instance creation time.

Validation and Transformation

// Required field: throw if initial value is falsy
function required(_value: undefined, context: ClassFieldDecoratorContext) {
  return function (this: any, initial: any) {
    if (initial === undefined || initial === null || initial === '') {
      throw new Error(`Field "${String(context.name)}" is required`);
    }
    return initial;
  };
}

// Min-length string validator
function minLength(min: number) {
  return function (_value: undefined, context: ClassFieldDecoratorContext) {
    return function (this: any, initial: string) {
      if (typeof initial === 'string' && initial.length < min) {
        throw new Error(
          `Field "${String(context.name)}" must be >= ${min} chars (got ${initial.length})`
        );
      }
      return initial;
    };
  };
}

// Trim whitespace on initialization
function trim(_value: undefined, context: ClassFieldDecoratorContext) {
  return function (this: any, initial: string) {
    return typeof initial === 'string' ? initial.trim() : initial;
  };
}

// Transform to lowercase
function lowercase(_value: undefined, context: ClassFieldDecoratorContext) {
  return function (this: any, initial: string) {
    return typeof initial === 'string' ? initial.toLowerCase() : initial;
  };
}

// Clamp number to a range
function clamp(min: number, max: number) {
  return function (_value: undefined, context: ClassFieldDecoratorContext) {
    return function (this: any, initial: number) {
      return Math.max(min, Math.min(max, initial));
    };
  };
}

class UserProfile {
  @required
  @minLength(2)
  @trim
  name: string = '  Alice  ';       // stored as 'Alice'

  @lowercase
  @trim
  email: string = '  ALICE@EX.COM '; // stored as 'alice@ex.com'

  @clamp(0, 150)
  age: number = 200;                 // clamped to 150

  @required
  id: string = crypto.randomUUID();
}

const profile = new UserProfile();
console.log(profile.name);   // 'Alice'
console.log(profile.email);  // 'alice@ex.com'
console.log(profile.age);    // 150

Parameter Decorators

Parameter decorators are only available in legacy experimental decorators — TC39 Stage 3 decorators do not yet include parameter decorators. They are heavily used in NestJS for route parameter extraction (@Param(), @Body(), @Query()) and in InversifyJS for named injection tokens. A parameter decorator has the signature (target: Object, propertyKey: string | symbol, parameterIndex: number) => void.

// Legacy parameter decorators (requires experimentalDecorators: true)
import 'reflect-metadata';

const PARAM_METADATA_KEY = Symbol('param_metadata');

interface ParamMetadata {
  index: number;
  key: string;
  source: 'body' | 'query' | 'param' | 'header';
}

// @Body('key') — extract field from request body
function Body(key?: string) {
  return function (
    target: Object,
    propertyKey: string | symbol,
    parameterIndex: number
  ) {
    const existing: ParamMetadata[] =
      Reflect.getMetadata(PARAM_METADATA_KEY, target, propertyKey) ?? [];
    existing.push({ index: parameterIndex, key: key ?? '', source: 'body' });
    Reflect.defineMetadata(PARAM_METADATA_KEY, existing, target, propertyKey);
  };
}

// @Query('key') — extract field from query string
function Query(key: string) {
  return function (
    target: Object,
    propertyKey: string | symbol,
    parameterIndex: number
  ) {
    const existing: ParamMetadata[] =
      Reflect.getMetadata(PARAM_METADATA_KEY, target, propertyKey) ?? [];
    existing.push({ index: parameterIndex, key, source: 'query' });
    Reflect.defineMetadata(PARAM_METADATA_KEY, existing, target, propertyKey);
  };
}

// @Param('id') — extract URL route parameter
function Param(key: string) {
  return function (
    target: Object,
    propertyKey: string | symbol,
    parameterIndex: number
  ) {
    const existing: ParamMetadata[] =
      Reflect.getMetadata(PARAM_METADATA_KEY, target, propertyKey) ?? [];
    existing.push({ index: parameterIndex, key, source: 'param' });
    Reflect.defineMetadata(PARAM_METADATA_KEY, existing, target, propertyKey);
  };
}

class UserController {
  createUser(
    @Body('name') name: string,
    @Body('email') email: string,
    @Query('role') role: string
  ) {
    return { name, email, role };
  }

  getUser(@Param('id') id: string) {
    return { id };
  }
}

Accessor Decorators and the accessor Keyword

TypeScript 5.0 introduced a new accessor keyword that auto-generates a getter/setter pair backed by a private storage field. An accessor decorator receives a ClassAccessorDecoratorTarget and returns a ClassAccessorDecoratorResult with optional get, set, and init hooks. This is the cleanest way to implement observable/reactive properties.

// TC39 accessor decorator: observable reactive fields
type ChangeListener<T> = (oldValue: T, newValue: T, name: string) => void;

const listeners = new WeakMap<object, Map<string, ChangeListener<any>[]>>();

function observable(
  target: ClassAccessorDecoratorTarget<any, any>,
  context: ClassAccessorDecoratorContext
) {
  const { get: originalGet, set: originalSet } = target;
  const name = String(context.name);

  return {
    get(this: any) {
      return originalGet.call(this);
    },
    set(this: any, newValue: any) {
      const oldValue = originalGet.call(this);
      if (oldValue !== newValue) {
        originalSet.call(this, newValue);
        const listenerMap = listeners.get(this);
        listenerMap?.get(name)?.forEach((fn) => fn(oldValue, newValue, name));
      }
    },
    init(this: any, value: any) {
      if (!listeners.has(this)) listeners.set(this, new Map());
      return value;
    },
  };
}

// Validated accessor: enforce constraints on setter
function validated(validator: (v: any) => boolean, message: string) {
  return function (
    target: ClassAccessorDecoratorTarget<any, any>,
    context: ClassAccessorDecoratorContext
  ) {
    const { get, set } = target;
    return {
      get(this: any) { return get.call(this); },
      set(this: any, value: any) {
        if (!validator(value)) {
          throw new RangeError(
            `Invalid value "${value}" for "${String(context.name)}": ${message}`
          );
        }
        set.call(this, value);
      },
    };
  };
}

class Store {
  @observable
  accessor count = 0;

  @observable
  @validated((v) => v.length >= 2, 'must be at least 2 characters')
  accessor username = 'guest';

  increment() { this.count++; }
  decrement() { this.count = Math.max(0, this.count - 1); }
}

const store = new Store();
// Attach listener (requires helper not shown for brevity)
store.count = 5;       // triggers observable set
store.username = 'A';  // throws: must be at least 2 characters

Decorator Factories: Parameterized Decorators

A decorator factory is a function that returns a decorator. This is the standard way to pass configuration to a decorator. When you write @MinLength(8), TypeScript first calls MinLength(8), which returns the actual decorator function that is then applied to the target. All of TypeScript's most popular decorator usage — NestJS routes, TypeORM columns, class-validator rules — uses decorator factories.

// Configurable debounce decorator factory
function debounce(waitMs: number) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    let timer: ReturnType<typeof setTimeout>;
    return function (this: any, ...args: any[]) {
      clearTimeout(timer);
      timer = setTimeout(() => target.call(this, ...args), waitMs);
    };
  };
}

// Deprecation warning decorator factory
function deprecated(message?: string) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    const name = String(context.name);
    return function (this: any, ...args: any[]) {
      console.warn(
        "DEPRECATED: " + name + " is deprecated" + (message ? ': ' + message : '') + ". " +
        "Avoid using it in new code."
      );
      return target.call(this, ...args);
    };
  };
}

// Authorization guard decorator factory
function authorize(...requiredRoles: string[]) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    const name = String(context.name);
    return function (this: any, ...args: any[]) {
      const userRoles: string[] = this.currentUser?.roles ?? [];
      const hasRole = requiredRoles.some((r) => userRoles.includes(r));
      if (!hasRole) {
        throw new Error(
          `Unauthorized: ${name} requires roles: ${requiredRoles.join(', ')}`
        );
      }
      return target.call(this, ...args);
    };
  };
}

class SearchComponent {
  currentUser = { roles: ['admin'] };

  @debounce(300)
  onSearchInput(query: string) {
    console.log('Searching for:', query);
  }

  @deprecated('Use findById instead')
  findUser(id: number) {
    return { id };
  }

  @authorize('admin', 'moderator')
  deleteContent(id: string) {
    return `Deleted ${id}`;
  }
}

Decorator Composition: Order of Execution

When multiple decorators are applied to a single declaration, they follow function composition order: factories are evaluated top-to-bottom, but the resulting decorator functions are executed bottom-to-top. This is identical to f(g(method)) where @f is listed above @g. Understanding this order is critical when decorators depend on each other.

// Demonstrating decorator composition order
function trace(label: string) {
  return function (target: Function, context: ClassMethodDecoratorContext) {
    console.log(`Evaluating decorator: ${label}`);
    return function (this: any, ...args: any[]) {
      console.log(`Entering: ${label}`);
      const result = target.call(this, ...args);
      console.log(`Exiting: ${label}`);
      return result;
    };
  };
}

class Pipeline {
  @trace('A')    // Evaluated 1st, called 1st (outermost)
  @trace('B')    // Evaluated 2nd, called 2nd
  @trace('C')    // Evaluated 3rd, called 3rd (innermost, wraps original)
  process(data: string) {
    console.log('Processing:', data);
    return data.toUpperCase();
  }
}

// Output when pipeline.process('hello') is called:
// Evaluating decorator: A
// Evaluating decorator: B
// Evaluating decorator: C
// Entering: A
// Entering: B
// Entering: C
// Processing: hello
// Exiting: C
// Exiting: B
// Exiting: A

// Real-world composition pattern:
// Order matters — validate before authorize, log after both
class OrderService {
  @log()           // outermost wrapper
  @authorize('user')
  @validate({ amount: (v) => v > 0 })  // innermost, runs first
  async createOrder(input: { amount: number; item: string }) {
    return { orderId: crypto.randomUUID(), ...input };
  }
}

Metadata Reflection: reflect-metadata and emitDecoratorMetadata

The reflect-metadata package polyfills the Reflect.metadata API, enabling decorators to read and write metadata on class constructors and their members. When emitDecoratorMetadata: true is set in tsconfig, TypeScript automatically emits three metadata keys at compile time:

  • design:type — the declared type of a property
  • design:paramtypes — array of constructor/method parameter types
  • design:returntype — return type of a method
// reflect-metadata usage (legacy decorators)
import 'reflect-metadata';

// Custom metadata keys
const VALIDATION_KEY = Symbol('validations');
const INJECTABLE_KEY = Symbol('injectable');

// Store validation rules in metadata
function IsString(_target: Object, propertyKey: string | symbol) {
  const rules = Reflect.getMetadata(VALIDATION_KEY, _target, propertyKey) ?? [];
  rules.push({ type: 'string' });
  Reflect.defineMetadata(VALIDATION_KEY, rules, _target, propertyKey);
}

function IsNumber(_target: Object, propertyKey: string | symbol) {
  const rules = Reflect.getMetadata(VALIDATION_KEY, _target, propertyKey) ?? [];
  rules.push({ type: 'number' });
  Reflect.defineMetadata(VALIDATION_KEY, rules, _target, propertyKey);
}

// Read emitted design:paramtypes to discover dependencies
function Injectable() {
  return function (target: Function) {
    Reflect.defineMetadata(INJECTABLE_KEY, true, target);
    const types = Reflect.getMetadata('design:paramtypes', target) ?? [];
    console.log(`${target.name} depends on:`, types.map((t: any) => t.name));
  };
}

class Logger {
  log(message: string) { console.log(`[LOG] ${message}`); }
}

class Database {
  query(sql: string) { return []; }
}

@Injectable()
class UserService {
  constructor(
    private logger: Logger,   // TypeScript emits Logger in design:paramtypes
    private db: Database      // TypeScript emits Database in design:paramtypes
  ) {}
}
// Console: "UserService depends on: ['Logger', 'Database']"

// Validate an object against its stored metadata rules
function validateObject<T extends object>(obj: T): string[] {
  const errors: string[] = [];
  for (const key of Object.keys(obj) as (keyof T)[]) {
    const rules = Reflect.getMetadata(VALIDATION_KEY, obj, key as string) ?? [];
    const value = obj[key];
    for (const rule of rules) {
      if (rule.type === 'string' && typeof value !== 'string') {
        errors.push(`${String(key)} must be a string`);
      }
      if (rule.type === 'number' && typeof value !== 'number') {
        errors.push(`${String(key)} must be a number`);
      }
    }
  }
  return errors;
}

Real-World Patterns: NestJS and TypeORM

NestJS Dependency Injection Decorators

// NestJS-style decorators (simplified implementation)
import 'reflect-metadata';

const MODULE_METADATA = {
  imports: 'imports',
  controllers: 'controllers',
  providers: 'providers',
  exports: 'exports',
};

function Module(metadata: {
  imports?: any[];
  controllers?: any[];
  providers?: any[];
  exports?: any[];
}) {
  return function (target: Function) {
    for (const [key, value] of Object.entries(metadata)) {
      Reflect.defineMetadata(key, value, target);
    }
  };
}

function Controller(path: string): ClassDecorator {
  return (target) => Reflect.defineMetadata('path', path, target);
}

function Injectable(): ClassDecorator {
  return (target) => Reflect.defineMetadata('injectable', true, target);
}

function Get(path: string): MethodDecorator {
  return (target, key, descriptor) => {
    Reflect.defineMetadata('method', 'GET', descriptor.value!);
    Reflect.defineMetadata('path', path, descriptor.value!);
  };
}

function Post(path: string): MethodDecorator {
  return (target, key, descriptor) => {
    Reflect.defineMetadata('method', 'POST', descriptor.value!);
    Reflect.defineMetadata('path', path, descriptor.value!);
  };
}

function UseGuards(...guards: Function[]): MethodDecorator {
  return (target, key, descriptor) => {
    Reflect.defineMetadata('guards', guards, descriptor.value!);
  };
}

function UseInterceptors(...interceptors: Function[]): MethodDecorator {
  return (target, key, descriptor) => {
    Reflect.defineMetadata('interceptors', interceptors, descriptor.value!);
  };
}

@Injectable()
class UserRepository {
  async findById(id: string) { return { id, name: 'Alice' }; }
  async save(user: any) { return user; }
}

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

  async getUser(id: string) { return this.userRepo.findById(id); }
}

@Controller('/users')
class UserController {
  constructor(private readonly userService: UserService) {}

  @Get('/')
  @UseInterceptors(LoggingInterceptor)
  findAll() { return this.userService.getUser('all'); }

  @Get('/:id')
  findOne() { return {}; }

  @Post('/')
  @UseGuards(AuthGuard, RolesGuard)
  create() { return {}; }
}

@Module({
  controllers: [UserController],
  providers: [UserService, UserRepository],
  exports: [UserService],
})
class UserModule {}

TypeORM Entity Decorators

// TypeORM decorator patterns (real usage)
import {
  Entity, Column, PrimaryGeneratedColumn, Index,
  CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
  ManyToOne, OneToMany, JoinColumn, BeforeInsert, BeforeUpdate,
} from 'typeorm';

@Entity('users')
@Index(['email'], { unique: true })
class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 100 })
  firstName: string;

  @Column({ type: 'varchar', length: 100 })
  lastName: string;

  @Column({ type: 'varchar', length: 255, unique: true })
  email: string;

  @Column({ type: 'varchar', select: false }) // excluded from SELECT by default
  passwordHash: string;

  @Column({ type: 'boolean', default: true })
  isActive: boolean;

  @Column({ type: 'jsonb', nullable: true })
  metadata: Record<string, unknown> | null;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date | null; // enables soft delete

  @OneToMany(() => Post, (post) => post.author, { cascade: ['insert', 'update'] })
  posts: Post[];

  @BeforeInsert()
  @BeforeUpdate()
  normalizeEmail() {
    this.email = this.email.toLowerCase().trim();
  }
}

@Entity('posts')
class Post {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 500 })
  title: string;

  @Column({ type: 'text' })
  body: string;

  @Column({ type: 'varchar', length: 500, nullable: true })
  slug: string | null;

  @ManyToOne(() => User, (user) => user.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'author_id' })
  author: User;

  @CreateDateColumn()
  createdAt: Date;
}

TC39 Standard vs Legacy Decorators: Full Comparison

The two decorator systems are not interchangeable. The table below summarizes every significant difference to help you decide which to use and how to migrate.

FeatureLegacy (experimentalDecorators)TC39 Stage 3 (TS 5.0+)
tsconfig flagexperimentalDecorators: trueNone required
TypeScript versionAll versions supporting decorators5.0+
Class decorator signature(constructor: T) => T | void(target: T, ctx: ClassDecoratorContext) => T | void
Method decorator signature(target, key, descriptor)(fn: Function, ctx: ClassMethodDecoratorContext) => Function | void
Field/property decoratorPropertyDecorator (no initializer)Returns initializer function (receives/returns initial value)
Accessor decoratorDecorates explicit get/setDecorates "accessor" auto-accessor fields
Parameter decoratorsSupportedNot yet in spec
Metadata APIReflect.metadata / Reflect.defineMetadatacontext.metadata (shared object)
emitDecoratorMetadataSupported (design:paramtypes etc.)Not applicable
Auto-accessor keywordNot available"accessor" keyword
Decorator orderSameSame (bottom-to-top execution)
Private fields (#)Limited supportFull support via context.private
Static membersSupportedSupported via context.static
Framework supportAngular, NestJS, TypeORM, MobX, class-validatorNew libraries; major frameworks migrating
StabilityFrozen (no new features)Actively maintained, future additions expected

Building a Mini DI Container with Decorators

To solidify everything, here is a complete, working mini dependency injection container built entirely with legacy decorators and reflect-metadata. This is the conceptual core of NestJS's and InversifyJS's IoC containers.

// mini-di.ts — A complete DI container in ~80 lines
import 'reflect-metadata';

// ── Container ──────────────────────────────────────────────────────────
type Constructor<T = any> = new (...args: any[]) => T;

class Container {
  private registry = new Map<string | symbol, Constructor>();
  private singletons = new Map<string | symbol, any>();

  register(token: string | symbol, implementation: Constructor) {
    this.registry.set(token, implementation);
  }

  resolve<T>(token: string | symbol | Constructor<T>): T {
    // If passed a constructor directly, resolve by constructor
    if (typeof token === 'function') {
      return this.instantiate(token as Constructor<T>);
    }
    const Ctor = this.registry.get(token);
    if (!Ctor) throw new Error(`No provider for token: ${String(token)}`);
    return this.instantiate<T>(Ctor as Constructor<T>);
  }

  private instantiate<T>(Ctor: Constructor<T>): T {
    // Check singleton cache
    if (this.singletons.has(Ctor as any)) {
      return this.singletons.get(Ctor as any);
    }

    // Read constructor parameter types emitted by TypeScript
    const paramTypes: Constructor[] =
      Reflect.getMetadata('design:paramtypes', Ctor) ?? [];

    // Recursively resolve each dependency
    const deps = paramTypes.map((dep) => {
      if (!dep || dep === Object) {
        throw new Error(
          "Cannot resolve dependency of " + Ctor.name + ": " +
          "check that all params are decorated classes with @Injectable"
        );
      }
      return this.instantiate(dep);
    });

    const instance = new Ctor(...deps);

    // If marked as singleton, cache it
    if (Reflect.getMetadata('singleton', Ctor)) {
      this.singletons.set(Ctor as any, instance);
    }

    return instance;
  }
}

// Global container instance (can be module-scoped in real apps)
export const container = new Container();

// ── Decorators ──────────────────────────────────────────────────────────
function Injectable(): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata('injectable', true, target);
  };
}

function Singleton(): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata('injectable', true, target);
    Reflect.defineMetadata('singleton', true, target);
  };
}

function Inject(token: string | symbol): ParameterDecorator {
  return (target, propertyKey, parameterIndex) => {
    const tokens =
      Reflect.getMetadata('inject:tokens', target, propertyKey!) ?? [];
    tokens[parameterIndex] = token;
    Reflect.defineMetadata('inject:tokens', tokens, target, propertyKey!);
  };
}

// ── Services ───────────────────────────────────────────────────────────
@Singleton()
class ConfigService {
  get(key: string): string {
    return process.env[key] ?? '';
  }
}

@Injectable()
class Logger {
  constructor(private config: ConfigService) {}

  log(message: string) {
    const level = this.config.get('LOG_LEVEL') || 'INFO';
    console.log(`[${level}] ${message}`);
  }
}

@Injectable()
class DatabaseService {
  constructor(
    private logger: Logger,
    private config: ConfigService
  ) {
    this.logger.log('DatabaseService initialized');
  }

  query(sql: string) {
    this.logger.log(`Executing: ${sql}`);
    return [];
  }
}

@Injectable()
class UserService {
  constructor(
    private db: DatabaseService,
    private logger: Logger
  ) {}

  findAll() {
    this.logger.log('Finding all users');
    return this.db.query('SELECT * FROM users');
  }
}

// ── Bootstrap ──────────────────────────────────────────────────────────
// Resolve the entire tree automatically
const userService = container.resolve(UserService);
userService.findAll();
// Logs:
// [INFO] DatabaseService initialized
// [INFO] Finding all users
// [INFO] Executing: SELECT * FROM users

class-validator Integration Pattern

class-validator is one of the most popular decorator-based validation libraries, used extensively in NestJS for DTO validation. It relies entirely on legacy experimental decorators and reflect-metadata. Here is the canonical pattern and how it integrates with NestJS pipes:

// class-validator + class-transformer pattern (NestJS DTOs)
import {
  IsString, IsEmail, IsNumber, IsOptional, IsEnum,
  MinLength, MaxLength, Min, Max, IsUrl, IsBoolean,
  ValidateNested, IsArray, ArrayMinSize,
} from 'class-validator';
import { Type, Expose } from 'class-transformer';
import { validate } from 'class-validator';

enum UserRole {
  Admin = 'admin',
  User = 'user',
  Moderator = 'moderator',
}

class AddressDto {
  @IsString()
  @MinLength(5)
  street: string;

  @IsString()
  city: string;

  @IsString()
  @MinLength(2)
  @MaxLength(2)
  countryCode: string; // e.g. 'US'
}

class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8, { message: 'Password must be at least 8 characters' })
  password: string;

  @IsNumber()
  @Min(13)
  @Max(120)
  age: number;

  @IsEnum(UserRole)
  @IsOptional()
  role?: UserRole = UserRole.User;

  @IsUrl()
  @IsOptional()
  website?: string;

  @IsBoolean()
  @IsOptional()
  newsletter?: boolean;

  @ValidateNested()   // validates nested object
  @Type(() => AddressDto)  // needed for class-transformer
  @IsOptional()
  address?: AddressDto;

  @IsArray()
  @ArrayMinSize(1)
  @IsString({ each: true })
  @IsOptional()
  tags?: string[];
}

// Usage with class-transformer
import { plainToInstance } from 'class-transformer';

async function validateDto(plain: unknown) {
  const dto = plainToInstance(CreateUserDto, plain);
  const errors = await validate(dto);
  if (errors.length > 0) {
    const messages = errors.map((e) =>
      Object.values(e.constraints ?? {}).join(', ')
    );
    throw new Error(`Validation failed: ${messages.join('; ')}`);
  }
  return dto;
}

// NestJS ValidationPipe handles this automatically:
// app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));

Practical Decorator Patterns Reference

PatternDecorator TypeUse CaseSystem
@log / @traceMethodStructured logging, call trackingBoth
@timing / @benchmarkMethodPerformance profilingBoth
@memoize / @cache(ttl)MethodExpensive pure functionsBoth
@retry(n, delay)MethodNetwork/API resilienceBoth
@rateLimit(n, ms)MethodAPI throttling, DOS protectionBoth
@debounce(ms)MethodSearch inputs, resize handlersBoth
@authorize(...roles)MethodRBAC access controlBoth
@deprecated(msg)MethodAPI migration warningsBoth
@singletonClassShared resources (DB, config)Both
@sealed / @frozenClassImmutable classesTC39
@withTimestampsClassAudit fields on entitiesBoth
@observableAccessorReactive state managementTC39
@validated(fn)Accessor / FieldRuntime type enforcementTC39
@required / @trimFieldData normalization on initTC39
@Injectable / @SingletonClassDI container registrationLegacy
@Controller(path)ClassHTTP routing metadataLegacy
@Get/@Post/@Put(path)MethodRoute handler registrationLegacy
@Entity / @ColumnClass/FieldORM schema mappingLegacy
@IsEmail / @MinLengthPropertyDTO input validationLegacy
@Body / @Query / @ParamParameterRequest data extractionLegacy

TypeScript Configuration Tips for Decorators

// Full production tsconfig for a NestJS project (legacy decorators)
{
  "compilerOptions": {
    "module": "CommonJS",
    "moduleResolution": "node",
    "target": "ES2022",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,
    "declaration": true,
    "strict": true,
    "strictPropertyInitialization": false, // needed for entity classes
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  }
}

// Full tsconfig for TC39 decorators (new projects)
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,
    "strict": true,
    // No experimentalDecorators needed
    // No emitDecoratorMetadata needed
    "skipLibCheck": true
  }
}

// Common pitfalls and fixes:

// 1. "Unable to resolve signature of class decorator"
//    → Make sure target is ES2022+, not ES5
//    → For TC39: check you're on TypeScript 5.0+

// 2. "Cannot read properties of undefined (reading 'prototype')"
//    → You forgot: import 'reflect-metadata'; at app entry

// 3. "Parameter 'target' implicitly has an 'any' type"
//    → Add explicit types to legacy decorator parameters

// 4. Decorator works in dev but not in build
//    → ts-node uses tsconfig.json but ts-jest may not
//    → Add: { preset: 'ts-jest', globals: { 'ts-jest': { tsconfig: 'tsconfig.json' } } }

// 5. "design:paramtypes" returns [Object] instead of real types
//    → The parameter type must be a concrete class, not an interface
//    → Interfaces are erased at runtime; use abstract classes or class tokens

When to Use (and When Not to Use) Decorators

Good Use Cases

  • Cross-cutting concerns — logging, caching, rate limiting, auth guards that apply to many methods across many classes
  • Framework integration — when a framework requires decorators (NestJS, Angular, TypeORM), use the framework conventions
  • Declarative schema definition — ORM column mapping, validation rules, OpenAPI spec generation where metadata-driven declaration is cleaner than imperative code
  • Aspect-Oriented Programming — separating infrastructure concerns (persistence, monitoring, security) from business logic
  • Observable/reactive state — TC39 accessor decorators for fine-grained reactivity without a full framework

Avoid Decorators When

  • A higher-order function would be simpler and equally readable — const wrappedFn = memoize(fn) is often clearer than @memoize fn() for standalone functions
  • The behavior is unique to one method in the entire codebase — adding a decorator for a single use case adds indirection without reuse benefit
  • You are building a library and don't want to force users into a specific decorator system or tsconfig setup
  • The team is unfamiliar with the execution order, and the debugging overhead outweighs the declarative benefit
  • You need to decorate non-class code — plain functions, arrow functions, module exports — decorators simply cannot be applied there

Frequently Asked Questions

What is the difference between TC39 Stage 3 and legacy experimental decorators?

TC39 Stage 3 decorators (TypeScript 5.0+) need no tsconfig flag and use a new API with a context object. Legacy decorators use experimentalDecorators: true and the older (target, key, descriptor) API. They are incompatible and cannot be mixed in the same file. See the full comparison table above.

How do I enable decorators in tsconfig.json?

For TC39 decorators: set target to ES2022 or higher — no other flag needed. For legacy decorators: add "experimentalDecorators": true and optionally "emitDecoratorMetadata": true for DI metadata. Install reflect-metadata if using DI containers.

What is a decorator factory?

A function that returns a decorator, used when you need to pass parameters: @retry(3, 500) calls retry(3, 500) first, which returns the decorator function. All NestJS, TypeORM, and class-validator decorators are factories. Use factories for any configurable decorator behavior.

In what order do stacked decorators execute?

Factory expressions are evaluated top-to-bottom; the resulting decorators execute bottom-to-top. @A @B method means A is evaluated first but B wraps the method first, then A wraps the result: equivalent to A(B(method)). In practice: put the outermost behavior (logging) at the top, innermost (validation) at the bottom.

What is reflect-metadata and why is it needed?

A polyfill for the Reflect.metadata API that enables decorators to attach and retrieve metadata. When emitDecoratorMetadata: true is set, TypeScript emits design:paramtypes, design:type, and design:returntype metadata at compile time, which DI containers read at runtime to resolve dependencies. Import it once at your app entry point.

Can I use decorators on plain (non-class) functions?

No. Decorators can only be applied to classes, class methods, class fields, class accessors, and constructor parameters. For plain functions, use higher-order functions: const logged = withLog(myFunction). This is often cleaner for standalone utilities anyway.

How do I build a DI container with decorators?

Use @Injectable() to mark classes, emitDecoratorMetadata to emit constructor types, and a Container.resolve() method that reads Reflect.getMetadata('design:paramtypes', Ctor) to recursively instantiate dependencies. See the full working example in the Mini DI Container section above.

What are accessor decorators and the "accessor" keyword?

Introduced in TypeScript 5.0, the accessor keyword auto-generates a getter/setter backed by a private field: accessor name = 'value'. Accessor decorators can intercept both reads and writes, enabling reactive/observable patterns. This is the TC39 standard replacement for manually writing get/set pairs with legacy decorators.

For more TypeScript content, explore our TypeScript Generics Guide, TypeScript Utility Types Reference, TypeScript Best Practices 2026, and try our JSON to TypeScript Converter and TypeScript to JavaScript Converter.

𝕏 Twitterin LinkedIn
Was dit nuttig?

Blijf op de hoogte

Ontvang wekelijkse dev-tips en nieuwe tools.

Geen spam. Altijd opzegbaar.

Try These Related Tools

TSJSON to TypeScriptJSTypeScript to JavaScriptGTGraphQL to TypeScriptGQLJSON to GraphQL

Related Articles

TypeScript Generics Complete Gids 2026: Van Basis tot Geavanceerde Patronen

Beheers TypeScript generics: typeparameters, constraints, conditionele types, mapped types, utility types en praktijkpatronen.

TypeScript Utility Types Spiekbrief: Partial, Pick, Omit en meer

Complete referentie voor TypeScript utility types met praktische voorbeelden. Partial, Required, Pick, Omit, Record, Exclude, Extract, ReturnType en geavanceerde patronen.

TypeScript vs JavaScript: Wanneer welke gebruiken

Praktische vergelijking van TypeScript en JavaScript. Type-veiligheid, codevoorbeelden, migratiestrategieen, prestaties, ecosysteem en beslissingskader.

TypeScript Generics Uitgelegd: Praktische Gids met Voorbeelden

Beheers TypeScript generics van basis tot geavanceerde patronen.