Back to Blog

TypeScript Decorators: TC39 Stage 3, Class Field Decorators, and NestJS DI Patterns

Master TypeScript decorators with TC39 Stage 3 semantics: implement class, method, accessor, and field decorators, use metadata reflection for dependency injection, and understand NestJS decorator internals.

Viprasol Tech Team
September 29, 2026
13 min read

TypeScript 5.0 shipped the TC39 Stage 3 decorator standard, replacing the legacy experimentalDecorators system that had been in use since TypeScript 1.5. The new decorators are a breaking change in semantics โ€” the execution order, argument shape, and metadata APIs all changed.

If you're starting a new project in 2026, use the Stage 3 decorators. If you're on NestJS or other frameworks still using experimentalDecorators: true, stay put until those frameworks migrate โ€” mixing the two systems in one project isn't supported.


The Two Decorator Systems

// tsconfig.json โ€” pick one system, not both

// Legacy (experimentalDecorators) โ€” NestJS, TypeORM, class-validator still use this
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true  // Required for reflect-metadata
  }
}

// TC39 Stage 3 (new standard, TypeScript 5.0+)
{
  "compilerOptions": {
    // No experimentalDecorators flag needed
    // emitDecoratorMetadata NOT supported in new system
    // Use explicit metadata instead
  }
}

TC39 Stage 3 Decorators

Class Decorator

// src/decorators/singleton.ts
// TC39 Stage 3 syntax

type ClassDecorator<T extends abstract new (...args: any) => any> = (
  target: T,
  context: ClassDecoratorContext<T>
) => T | void;

// Singleton pattern via class decorator
export function Singleton<T extends new (...args: any) => any>(
  target: T,
  context: ClassDecoratorContext<T>
): T {
  let instance: InstanceType<T> | undefined;

  // Return a new class that wraps the original
  const singleton = class extends target {
    constructor(...args: any[]) {
      if (instance) return instance;
      super(...args);
      instance = this as InstanceType<T>;
    }
  } as T;

  // Preserve the original class name for debugging
  Object.defineProperty(singleton, "name", { value: target.name });

  return singleton;
}

// Usage
@Singleton
class DatabaseConnection {
  private connected = false;

  connect(url: string) {
    if (this.connected) return;
    console.log(`Connecting to ${url}`);
    this.connected = true;
  }
}

const db1 = new DatabaseConnection();
const db2 = new DatabaseConnection();
console.log(db1 === db2); // true โ€” same instance

Method Decorator

// src/decorators/retry.ts
type MethodDecorator = (
  target: Function,
  context: ClassMethodDecoratorContext
) => Function | void;

interface RetryOptions {
  attempts: number;
  delayMs: number;
  backoff?: "fixed" | "exponential";
  retryOn?: (error: unknown) => boolean;
}

export function Retry(options: RetryOptions) {
  return function <T extends (...args: any[]) => Promise<any>>(
    method: T,
    context: ClassMethodDecoratorContext
  ): T {
    const methodName = String(context.name);

    return async function (this: unknown, ...args: Parameters<T>): Promise<ReturnType<T>> {
      let lastError: unknown;

      for (let attempt = 1; attempt <= options.attempts; attempt++) {
        try {
          return await method.apply(this, args);
        } catch (error) {
          lastError = error;

          // Check if we should retry this error type
          if (options.retryOn && !options.retryOn(error)) {
            throw error; // Non-retryable error
          }

          if (attempt === options.attempts) break;

          const delay =
            options.backoff === "exponential"
              ? options.delayMs * Math.pow(2, attempt - 1)
              : options.delayMs;

          console.warn(
            `[Retry] ${methodName} attempt ${attempt}/${options.attempts} failed. ` +
            `Retrying in ${delay}ms...`
          );

          await new Promise((resolve) => setTimeout(resolve, delay));
        }
      }

      throw lastError;
    } as T;
  };
}

// Usage
class PaymentService {
  @Retry({
    attempts: 3,
    delayMs: 500,
    backoff: "exponential",
    retryOn: (err) => err instanceof NetworkError,
  })
  async chargeCard(amount: number, token: string): Promise<{ chargeId: string }> {
    return await stripeClient.charges.create({ amount, source: token });
  }
}

Accessor Decorator (New in Stage 3)

// src/decorators/validated.ts
// Accessor decorators apply to get/set pairs โ€” new in Stage 3

export function Validated<T>(validator: (value: T) => void) {
  return function (
    target: ClassAccessorDecoratorTarget<unknown, T>,
    context: ClassAccessorDecoratorContext
  ): ClassAccessorDecoratorResult<unknown, T> {
    return {
      get(): T {
        return target.get.call(this);
      },
      set(value: T): void {
        validator(value);
        target.set.call(this, value);
      },
      init(initialValue: T): T {
        if (initialValue !== undefined) {
          validator(initialValue);
        }
        return initialValue;
      },
    };
  };
}

function positiveNumber(value: number) {
  if (value <= 0) throw new RangeError(`Value must be positive, got ${value}`);
}

function nonEmptyString(value: string) {
  if (!value?.trim()) throw new TypeError("Value cannot be empty");
}

class Product {
  @Validated(nonEmptyString)
  accessor name: string = "";

  @Validated(positiveNumber)
  accessor price: number = 0;

  @Validated(positiveNumber)
  accessor stock: number = 0;
}

const product = new Product();
product.name = "Widget";   // OK
product.price = 9.99;      // OK
product.price = -1;        // Throws RangeError: Value must be positive, got -1

Field Decorator

// src/decorators/logged.ts
// Field decorators intercept field initialization

export function Logged<T>(
  target: undefined,
  context: ClassFieldDecoratorContext
): (initialValue: T) => T {
  const fieldName = String(context.name);

  return function (initialValue: T): T {
    console.log(`[Init] ${context.name as string} = ${JSON.stringify(initialValue)}`);
    return initialValue;
  };
}

// Serialize class fields to JSON schema
const schemaFields = new Map<string, { type: string }>();

export function JsonField(type: "string" | "number" | "boolean") {
  return function <T>(
    target: undefined,
    context: ClassFieldDecoratorContext
  ): void {
    // context.addInitializer runs after the field is initialized
    context.addInitializer(function (this: object) {
      // Register this field in the class's schema
      const className = this.constructor.name;
      schemaFields.set(`${className}.${String(context.name)}`, { type });
    });
  };
}

export function getSchema(cls: new (...args: any[]) => any): Record<string, { type: string }> {
  const prefix = cls.name + ".";
  return Object.fromEntries(
    [...schemaFields.entries()]
      .filter(([key]) => key.startsWith(prefix))
      .map(([key, value]) => [key.slice(prefix.length), value])
  );
}

class User {
  @JsonField("string")
  name: string = "";

  @JsonField("string")
  email: string = "";

  @JsonField("number")
  age: number = 0;
}

console.log(getSchema(User));
// { name: { type: 'string' }, email: { type: 'string' }, age: { type: 'number' } }

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Metadata for Dependency Injection

TC39 Stage 3 decorators don't emit metadata automatically (unlike emitDecoratorMetadata). You manage metadata explicitly:

// src/di/container.ts
// Simple DI container using Symbol metadata

const INJECTABLE_KEY = Symbol("injectable");
const INJECT_KEY = Symbol("inject");

interface InjectableMetadata {
  token: string | symbol;
  scope: "singleton" | "transient";
}

// Store metadata on the class itself
export function Injectable(
  token?: string | symbol,
  scope: "singleton" | "transient" = "singleton"
) {
  return function <T extends abstract new (...args: any) => any>(
    target: T,
    context: ClassDecoratorContext<T>
  ): void {
    context.metadata[INJECTABLE_KEY] = {
      token: token ?? target.name,
      scope,
    } satisfies InjectableMetadata;
  };
}

// Mark constructor parameters for injection
export function Inject(token: string | symbol) {
  return function (
    target: undefined,
    context: ClassFieldDecoratorContext
  ): void {
    context.metadata[INJECT_KEY] = context.metadata[INJECT_KEY] ?? {};
    (context.metadata[INJECT_KEY] as Record<string | symbol, string | symbol>)[
      context.name
    ] = token;
  };
}

// DI Container
class Container {
  private registry = new Map<
    string | symbol,
    { factory: () => unknown; scope: "singleton" | "transient"; instance?: unknown }
  >();

  register<T>(
    token: string | symbol,
    factory: () => T,
    scope: "singleton" | "transient" = "singleton"
  ): void {
    this.registry.set(token, { factory, scope });
  }

  resolve<T>(token: string | symbol): T {
    const entry = this.registry.get(token);
    if (!entry) throw new Error(`No provider for token: ${String(token)}`);

    if (entry.scope === "singleton") {
      entry.instance ??= entry.factory();
      return entry.instance as T;
    }

    return entry.factory() as T;
  }
}

export const container = new Container();

// Register services
@Injectable("EmailService")
class EmailService {
  send(to: string, subject: string, body: string) {
    console.log(`Sending email to ${to}: ${subject}`);
  }
}

@Injectable("UserService")
class UserService {
  @Inject("EmailService")
  private emailService!: EmailService;

  createUser(email: string) {
    // In a real DI system, emailService would be injected by the container
    this.emailService.send(email, "Welcome!", "Thanks for joining.");
  }
}

container.register("EmailService", () => new EmailService());
container.register("UserService", () => new UserService());

NestJS Decorator Internals (Legacy System)

NestJS uses experimentalDecorators with reflect-metadata. Understanding how it works helps you debug injection issues:

// How @Injectable() works in NestJS (simplified)
import "reflect-metadata";

// NestJS's @Injectable() stores metadata that the IoC container reads
export function Injectable(): ClassDecorator {
  return (target: object) => {
    // Mark class as injectable
    Reflect.defineMetadata("injectable", true, target);

    // Constructor parameter types are auto-captured by emitDecoratorMetadata
    // The compiler emits: Reflect.metadata("design:paramtypes", [Dep1, Dep2])
    // NestJS reads this to know what to inject
    const paramTypes = Reflect.getMetadata("design:paramtypes", target) ?? [];
    console.log(`[Injectable] ${(target as any).name} depends on:`, 
      paramTypes.map((t: any) => t.name));
  };
}

// How @Controller() and @Get() build route metadata
export function Controller(prefix: string = ""): ClassDecorator {
  return (target: object) => {
    Reflect.defineMetadata("controller:prefix", prefix, target);
  };
}

export function Get(path: string = ""): MethodDecorator {
  return (target: object, propertyKey: string | symbol) => {
    // Store routes as an array on the class prototype
    const existingRoutes: RouteDefinition[] =
      Reflect.getMetadata("routes", target.constructor) ?? [];

    existingRoutes.push({
      method: "GET",
      path,
      handler: propertyKey as string,
    });

    Reflect.defineMetadata("routes", existingRoutes, target.constructor);
  };
}

interface RouteDefinition {
  method: string;
  path: string;
  handler: string;
}

// Read the route metadata to build the router
function buildRouter(controllerClass: new (...args: any[]) => any) {
  const prefix = Reflect.getMetadata("controller:prefix", controllerClass);
  const routes: RouteDefinition[] =
    Reflect.getMetadata("routes", controllerClass) ?? [];

  const instance = new controllerClass();

  for (const route of routes) {
    const fullPath = `${prefix}${route.path}`;
    console.log(`Registered: ${route.method} ${fullPath} โ†’ ${route.handler}`);
    // app.get(fullPath, instance[route.handler].bind(instance))
  }
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Decorator Composition Order

// Decorators apply bottom-up (innermost first)
class Service {
  @Retry({ attempts: 3, delayMs: 100 })  // Applied second (outer)
  @Memoize(60_000)                         // Applied first (inner)
  async fetchData(id: string): Promise<Data> {
    return api.get(`/data/${id}`);
  }
}

// Execution order:
// 1. Memoize wraps fetchData โ†’ returns memoized version
// 2. Retry wraps memoized version โ†’ returns retried+memoized version
// Result: cache check โ†’ on miss, retry up to 3 times โ†’ cache result

Memoize Decorator (Production Pattern)

// src/decorators/memoize.ts
export function Memoize(ttlMs: number = 60_000) {
  return function <T extends (...args: any[]) => Promise<any>>(
    method: T,
    context: ClassMethodDecoratorContext
  ): T {
    const cache = new Map<string, { value: unknown; expiresAt: number }>();

    return async function (this: unknown, ...args: Parameters<T>): Promise<ReturnType<T>> {
      const key = JSON.stringify(args);
      const cached = cache.get(key);

      if (cached && cached.expiresAt > Date.now()) {
        return cached.value as ReturnType<T>;
      }

      const value = await method.apply(this, args);

      cache.set(key, { value, expiresAt: Date.now() + ttlMs });

      // Prune expired entries periodically
      if (cache.size > 1000) {
        const now = Date.now();
        for (const [k, v] of cache.entries()) {
          if (v.expiresAt <= now) cache.delete(k);
        }
      }

      return value;
    } as T;
  };
}

See Also


Working With Viprasol

TypeScript's decorator system enables powerful meta-programming patterns for dependency injection, validation, caching, and cross-cutting concerns. Our TypeScript engineers design decorator-based frameworks that keep application code clean and testable while handling infrastructure concerns (retry, logging, caching) transparently.

TypeScript engineering โ†’ | Start a project โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.