MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

TypeScript Decorators: Patterns and Use Cases

Understand TypeScript decorators: class, method, property, and parameter decorators.

TypeScriptDecoratorsJavaScriptPatterns

By MinhVo

Introduction

TypeScript decorators provide a powerful metaprogramming syntax for annotating and modifying classes, methods, properties, and parameters at design time. Originally inspired by the Python decorator syntax and adopted as a TC39 Stage 2 proposal, decorators enable aspect-oriented programming patterns in TypeScript—allowing developers to separate cross-cutting concerns like logging, validation, caching, and access control from core business logic.

TypeScript decorators overview

Decorators are extensively used in frameworks like Angular, NestJS, and TypeORM, where they form the primary API for defining components, services, and database entities. Understanding decorators—from basic class and method decorators to advanced property and parameter decorators—is essential for working with these frameworks and for building your own decorator-based APIs. This guide covers every type of TypeScript decorator with practical, production-ready patterns.

Understanding Decorator Fundamentals

Enabling Decorators

Decorators require two compiler options in tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  • experimentalDecorators enables the Stage 2 decorator syntax
  • emitDecoratorMetadata generates metadata about decorated targets using reflect-metadata

Decorator Execution Order

Decorators execute in a specific, predictable order:

  1. Property/Parameter decorators (per instance, bottom-up)
  2. Method/Accessor decorators (per instance, bottom-up)
  3. Class decorators (top-down)
function First() {
  console.log("First(): evaluated");
  return function (target: any) {
    console.log("First(): called");
  };
}
 
function Second() {
  console.log("Second(): evaluated");
  return function (target: any) {
    console.log("Second(): called");
  };
}
 
@First()
@Second()
class Example {
  // Output:
  // First(): evaluated
  // Second(): evaluated
  // Second(): called
  // First(): called
}

Decorator execution order

Class Decorators

Class decorators receive the class constructor as their only argument. They can observe, modify, or replace the class definition entirely.

Basic Class Decorator

function Sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}
 
@Sealed
class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
}
 
// Attempting to add properties will fail silently or throw
// Greeter.newProp = "test"; // Error in strict mode

Class Decorator Factory

function Entity(tableName: string) {
  return function (constructor: Function) {
    constructor.prototype.tableName = tableName;
    constructor.prototype.save = function () {
      console.log(`Saving to ${tableName}:`, this);
    };
    constructor.prototype.find = function (id: string) {
      console.log(`Finding ${id} in ${tableName}`);
    };
  };
}
 
@Entity("users")
class User {
  id: string;
  name: string;
  email: string;
}
 
const user = new User();
(user as any).save(); // "Saving to users: User { ... }"

Extending Classes with Decorators

function Timestamped<T extends new (...args: any[]) => {}>(
  constructor: T
) {
  return class extends constructor {
    createdAt = new Date();
    updatedAt = new Date();
  };
}
 
function Versioned<T extends new (...args: any[]) => {}>(
  constructor: T
) {
  return class extends constructor {
    version = 1;
    incrementVersion() {
      this.version++;
    }
  };
}
 
@Timestamped
@Versioned
class Article {
  title: string;
  content: string;
}
 
const article = new Article();
console.log((article as any).createdAt); // Date object
console.log((article as any).version);   // 1

Method Decorators

Method decorators receive three arguments: the target (prototype for instance methods, constructor for static methods), the method name, and the property descriptor.

Logging Decorator

function Log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
 
  descriptor.value = function (...args: any[]) {
    console.log(`[${propertyKey}] Called with args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`[${propertyKey}] Returned:`, result);
    return result;
  };
 
  return descriptor;
}
 
class Calculator {
  @Log
  add(a: number, b: number): number {
    return a + b;
  }
 
  @Log
  multiply(a: number, b: number): number {
    return a * b;
  }
}

Memoization Decorator

function Memoize(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  const cache = new Map<string, any>();
 
  descriptor.value = function (...args: any[]) {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    const result = originalMethod.apply(this, args);
    cache.set(key, result);
    return result;
  };
 
  return descriptor;
}
 
class MathService {
  @Memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

Retry Decorator

function Retry(maxAttempts: number = 3, delay: number = 1000) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    const originalMethod = descriptor.value;
 
    descriptor.value = async function (...args: any[]) {
      let lastError: Error;
 
      for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
          return await originalMethod.apply(this, args);
        } catch (error) {
          lastError = error as Error;
          console.warn(
            `[${propertyKey}] Attempt ${attempt}/${maxAttempts} failed:`,
            (error as Error).message
          );
          if (attempt < maxAttempts) {
            await new Promise(resolve => setTimeout(resolve, delay * attempt));
          }
        }
      }
 
      throw lastError!;
    };
 
    return descriptor;
  };
}
 
class ApiService {
  @Retry(3, 1000)
  async fetchData(url: string): Promise<any> {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }
}

Method decorator patterns

Property Decorators

Property decorators receive the target and property name. They're typically used with Object.defineProperty to add getters/setters or with Reflect.metadata to attach metadata.

Observable Property Decorator

function Observable(
  target: any,
  propertyKey: string
) {
  const privateKey = `_${propertyKey}`;
  const subscribers = new Set<(value: any) => void>();
 
  Object.defineProperty(target, propertyKey, {
    get() {
      return this[privateKey];
    },
    set(value: any) {
      const oldValue = this[privateKey];
      this[privateKey] = value;
      if (oldValue !== value) {
        subscribers.forEach(cb => cb(value));
      }
    },
    enumerable: true,
    configurable: true,
  });
 
  // Add subscribe method
  target[`subscribe${propertyKey.charAt(0).toUpperCase() + propertyKey.slice(1)}`] =
    function (callback: (value: any) => void) {
      subscribers.add(callback);
      return () => subscribers.delete(callback);
    };
}
 
class UserProfile {
  @Observable
  name: string = "";
 
  @Observable
  email: string = "";
}
 
const profile = new UserProfile();
(profile as any).subscribeName((name: string) => {
  console.log("Name changed to:", name);
});
profile.name = "John"; // "Name changed to: John"

Default Value Decorator

function DefaultValue(value: any) {
  return function (target: any, propertyKey: string) {
    let currentValue = target[propertyKey] ?? value;
 
    Object.defineProperty(target, propertyKey, {
      get: () => currentValue,
      set: (newValue) => {
        currentValue = newValue ?? value;
      },
      enumerable: true,
      configurable: true,
    });
  };
}
 
class Settings {
  @DefaultValue("en")
  language: string;
 
  @DefaultValue(10)
  pageSize: number;
 
  @DefaultValue(true)
  notifications: boolean;
}

Parameter Decorators

Parameter decorators receive the target, parameter name, and parameter index. They're most commonly used for dependency injection.

Basic Parameter Decorator

function LogParam(
  target: any,
  propertyKey: string,
  parameterIndex: number
) {
  const metadataKey = `log_params_${propertyKey}`;
 
  if (Array.isArray(target[metadataKey])) {
    target[metadataKey].push(parameterIndex);
  } else {
    target[metadataKey] = [parameterIndex];
  }
}
 
class UserService {
  createUser(
    @LogParam name: string,
    @LogParam email: string,
    password: string
  ) {
    console.log("Creating user:", { name, email });
  }
}

Validation Parameter Decorator

function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
 
  descriptor.value = function (...args: any[]) {
    const validations = Reflect.getOwnMetadata(
      "validations",
      target,
      propertyKey
    ) ?? [];
 
    for (const { index, validate, message } of validations) {
      if (!validate(args[index])) {
        throw new Error(`Parameter ${index}: ${message}`);
      }
    }
 
    return originalMethod.apply(this, args);
  };
}
 
function Positive(target: any, propertyKey: string, parameterIndex: number) {
  const validations = Reflect.getOwnMetadata("validations", target, propertyKey) ?? [];
  validations.push({
    index: parameterIndex,
    validate: (value: any) => typeof value === "number" && value > 0,
    message: "Must be a positive number",
  });
  Reflect.defineMetadata("validations", validations, target, propertyKey);
}
 
function NotEmpty(target: any, propertyKey: string, parameterIndex: number) {
  const validations = Reflect.getOwnMetadata("validations", target, propertyKey) ?? [];
  validations.push({
    index: parameterIndex,
    validate: (value: any) => typeof value === "string" && value.length > 0,
    message: "Must not be empty",
  });
  Reflect.defineMetadata("validations", validations, target, propertyKey);
}
 
class OrderService {
  @Validate
  createOrder(
    @NotEmpty customerId: string,
    @Positive quantity: number,
    @Positive price: number
  ) {
    return { customerId, quantity, price };
  }
}

Step-by-Step Implementation

Building a Complete ORM with Decorators

// Column decorator
function Column(options?: { type?: string; nullable?: boolean }) {
  return function (target: any, propertyKey: string) {
    const columns = Reflect.getMetadata("columns", target.constructor) ?? [];
    columns.push({
      propertyKey,
      type: options?.type ?? "varchar",
      nullable: options?.nullable ?? false,
    });
    Reflect.defineMetadata("columns", columns, target.constructor);
  };
}
 
// Entity decorator
function Entity(tableName: string) {
  return function (constructor: Function) {
    Reflect.defineMetadata("tableName", tableName, constructor);
  };
}
 
// PrimaryGeneratedColumn decorator
function PrimaryGeneratedColumn() {
  return function (target: any, propertyKey: string) {
    Reflect.defineMetadata("primaryKey", propertyKey, target.constructor);
  };
}
 
// Repository
class Repository<T> {
  constructor(private entity: new () => T) {}
 
  async find(id: string): Promise<T | null> {
    const tableName = Reflect.getMetadata("tableName", this.entity);
    const primaryKey = Reflect.getMetadata("primaryKey", this.entity);
    // Database query implementation
    return null;
  }
 
  async save(entity: T): Promise<T> {
    const tableName = Reflect.getMetadata("tableName", this.entity);
    const columns = Reflect.getMetadata("columns", this.entity);
    // Database insert/update implementation
    return entity;
  }
}

ORM pattern visualization

Real-World Use Cases

Use Case 1: HTTP Client with Decorators

function Get(path: string) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.value = async function (...args: any[]) {
      const baseUrl = Reflect.getMetadata("baseUrl", target.constructor);
      const response = await fetch(`${baseUrl}${path}`);
      return response.json();
    };
  };
}
 
function Post(path: string) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.value = async function (body: any, ...args: any[]) {
      const baseUrl = Reflect.getMetadata("baseUrl", target.constructor);
      const response = await fetch(`${baseUrl}${path}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      return response.json();
    };
  };
}
 
function BaseUrl(url: string) {
  return function (constructor: Function) {
    Reflect.defineMetadata("baseUrl", url, constructor);
  };
}
 
@BaseUrl("https://api.example.com")
class UserApi {
  @Get("/users")
  async getUsers(): Promise<User[]> { return [] as any; }
 
  @Post("/users")
  async createUser(user: CreateUserDto): Promise<User> { return {} as any; }
}

Use Case 2: Dependency Injection Container

const container = new Map<string, any>();
 
function Injectable(token?: string) {
  return function (constructor: Function) {
    const injectToken = token ?? constructor.name;
    container.set(injectToken, constructor);
  };
}
 
function Inject(token: string) {
  return function (target: any, propertyKey: string, parameterIndex: number) {
    const injections = Reflect.getMetadata("injections", target) ?? [];
    injections.push({ index: parameterIndex, token });
    Reflect.defineMetadata("injections", injections, target);
  };
}
 
function resolve<T>(token: string): T {
  const Constructor = container.get(token);
  if (!Constructor) throw new Error(`No provider for ${token}`);
 
  const injections = Reflect.getMetadata("injections", Constructor) ?? [];
  const args = injections.map((inj: any) => resolve(inj.token));
 
  return new Constructor(...args);
}

Best Practices for Production

  1. Prefer decorator factories: Always use the factory pattern (returning a decorator function) to allow parameters.

  2. Use Reflect.metadata: For attaching metadata, use the reflect-metadata polyfill for consistent behavior.

  3. Preserve descriptors: When modifying method descriptors, ensure you preserve the original function's this context and return type.

  4. Document decorator side effects: If a decorator modifies class behavior, document it clearly in the class's JSDoc.

  5. Test decorated classes: Write tests that verify the decorator's effect on the decorated target, not just the decorator function in isolation.

  6. Consider Stage 3 migration: Plan for the transition to Stage 3 decorators, which have a different API.

  7. Use Symbol.metadata: When targeting modern environments, prefer Symbol.metadata over Reflect.metadata.

  8. Avoid deep nesting: Limit decorator stacking to 3-4 levels. Beyond that, consider refactoring.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing reflect-metadata importMetadata not availableImport reflect-metadata at entry point
Decorator order issuesUnexpected behaviorTest execution order; document expectations
this context lost in methodsRuntime errorsUse descriptor.value = function() not arrow functions
Metadata key collisionsData overwritesUse unique, namespaced metadata keys
Forgetting emitDecoratorMetadataMetadata not generatedEnable in tsconfig.json

Performance Optimization

Decorators run once at class definition time, so their performance impact is minimal for most use cases. However, method decorators that wrap every call (like logging or retry) add per-call overhead:

// Cache expensive decorator computations
function CachedMethod(ttl: number) {
  const metadataKey = Symbol("cached");
 
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
 
    descriptor.value = function (...args: any[]) {
      const cache = this[metadataKey] ?? (this[metadataKey] = new Map());
      const key = JSON.stringify(args);
      const cached = cache.get(key);
 
      if (cached && Date.now() - cached.timestamp < ttl * 1000) {
        return cached.value;
      }
 
      const result = originalMethod.apply(this, args);
      cache.set(key, { value: result, timestamp: Date.now() });
      return result;
    };
  };
}

Comparison with Alternatives

FeatureTypeScript DecoratorsJavaScript ProxiesHigher-Order Functions
Declarative syntaxYesNoNo
Class-level supportYesYesNo
Metadata supportBuilt-inManualManual
PerformanceOne-time setupPer-access overheadPer-call overhead
Framework integrationExcellentLimitedManual

Testing Strategies

import "reflect-metadata";
 
describe("Decorators", () => {
  describe("Class decorators", () => {
    it("should add metadata to class", () => {
      @Entity("users")
      class TestEntity {}
 
      const tableName = Reflect.getMetadata("tableName", TestEntity);
      expect(tableName).toBe("users");
    });
  });
 
  describe("Method decorators", () => {
    it("should intercept method calls", () => {
      const logs: string[] = [];
 
      function TestLog(target: any, key: string, desc: PropertyDescriptor) {
        const original = desc.value;
        desc.value = function (...args: any[]) {
          logs.push(`Called ${key}`);
          return original.apply(this, args);
        };
      }
 
      class TestClass {
        @TestLog
        doSomething() { return 42; }
      }
 
      const instance = new TestClass();
      instance.doSomething();
      expect(logs).toEqual(["Called doSomething"]);
    });
  });
});

Future Outlook

TypeScript decorators are evolving toward the Stage 3 standard, which uses DecoratorContext instead of the current target, key, descriptor API. This change will affect how all decorators are written, but the fundamental concepts remain the same. Frameworks like Angular and NestJS have committed to supporting both standards during the transition period.

Architecture Decision Records

When evaluating architectural choices for your project, documenting your decision-making process through Architecture Decision Records (ADRs) provides invaluable context for future team members and stakeholders. Each ADR captures the context, decision, and consequences of a specific architectural choice.

Creating Effective ADRs

An ADR should include the date of the decision, the status (proposed, accepted, deprecated, or superseded), the context that motivated the decision, the decision itself, and the expected consequences both positive and negative. This structured approach ensures that decisions are traceable and reversible when circumstances change.

# ADR-001: Choose React for Frontend Framework
 
## Status: Accepted
 
## Context
We need a frontend framework that supports component-based architecture,
has a large ecosystem, and provides good TypeScript support.
 
## Decision
We will use React 18+ with TypeScript for all new frontend projects.
 
## Consequences
- Large talent pool available for hiring
- Mature ecosystem with extensive third-party libraries
- Strong TypeScript integration
- Requires additional libraries for routing and state management

Decision Matrix for Technology Selection

Create a weighted decision matrix when comparing multiple options. List your evaluation criteria (performance, learning curve, ecosystem maturity, community support, long-term viability) and assign weights based on your project priorities. Score each option on a scale of 1-5 for each criterion, then calculate weighted totals.

This systematic approach removes emotion from technology decisions and provides a defensible rationale when stakeholders question your choices. Document the matrix alongside your ADR so future teams understand not just what was chosen, but why alternatives were rejected.

Reversibility and Migration Paths

Every architectural decision should include a migration path in case the decision needs to be reversed. Consider the cost of changing course at six months, twelve months, and two years. Decisions with low reversal costs can be made more aggressively, while irreversible decisions warrant extended evaluation periods and proof-of-concept implementations.

For example, choosing a CSS-in-JS library has a relatively low reversal cost since styles can be migrated incrementally component by component. However, choosing a database technology has a high reversal cost due to data migration complexity and potential schema changes throughout the codebase.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

TypeScript decorators are a powerful metaprogramming tool that enables clean separation of concerns through aspect-oriented programming. Key takeaways:

  1. Class decorators can observe, modify, or replace class definitions
  2. Method decorators wrap method behavior for cross-cutting concerns
  3. Property decorators enable reactive properties and metadata attachment
  4. Parameter decorators power dependency injection systems
  5. Decorator execution order is deterministic and predictable

Mastering all four decorator types unlocks the full potential of TypeScript's metaprogramming capabilities, enabling you to build clean, declarative APIs that separate business logic from infrastructure concerns.