•17 min read

TypeScript 5.8+ Stage 3 Decorators: Production Metaprogramming, DI & Runtime Validation

TypeScript 5.8+ Stage 3 Decorators: Production Metaprogramming, DI & Runtime Validation

TypeScript 5.8+ natively supports ECMAScript Stage 3 Decorators, marking a pivotal shift from the legacy experimentalDecorators flag. This native implementation provides a robust, standardized mechanism for metaprogramming, enabling powerful patterns like Dependency Injection (DI) and runtime validation without relying on non-standard features or polyfills. This guide details the practical application of these decorators, focusing on their architecture, implementation, and production utility.

Audio Briefing
0:00 / 0:00

Understanding Stage 3 Decorators

Stage 3 Decorators operate on class elements during definition, not instantiation. They are functions that receive specific contexts and allow modification or replacement of the decorated element. Unlike experimentalDecorators, the new specification provides a more predictable and powerful API, including access to private fields and the ability to add initializers.

Decorator Types and Signatures

The decorator function signature varies based on the target:

  1. Class Decorators: Applied to class declarations.

    type ClassDecorator = <T extends Function>(
      value: T,
      context: ClassDecoratorContext
    ) => T | void;
    

    context provides kind: 'class', name, and addInitializer.

  2. Method Decorators: Applied to methods within a class.

    type MethodDecorator = <T extends Function>(
      value: T,
      context: ClassMethodDecoratorContext
    ) => T | void;
    

    context provides kind: 'method', name, static, private, access, and addInitializer.

  3. Getter/Setter Decorators: Applied to accessors.

    type AccessorDecorator = <T>(
      value: ClassAccessorDecoratorTarget<T>,
      context: ClassAccessorDecoratorContext<ThisType<T>, T>
    ) => ClassAccessorDecoratorResult<ThisType<T>, T> | void;
    

    context provides kind: 'getter' or 'setter', name, static, private, access, and addInitializer.

  4. Field Decorators: Applied to class fields.

    type FieldDecorator = <T>(
      value: undefined, // Field decorators receive undefined as value
      context: ClassFieldDecoratorContext<ThisType<T>, T>
    ) => ((initialValue: T) => T) | void;
    

    context provides kind: 'field', name, static, private, access, and addInitializer. The return value is a function that modifies the field's initial value.

  5. Auto-Accessor Decorators: Applied to accessor properties.

    type AutoAccessorDecorator = <T>(
      value: ClassAccessorDecoratorTarget<T>,
      context: ClassAccessorDecoratorContext<ThisType<T>, T>
    ) => ClassAccessorDecoratorResult<ThisType<T>, T> | void;
    

    Similar to getter/setter, but for the combined accessor syntax.

Configuration

Ensure tsconfig.json is configured for native decorators:

{
  "compilerOptions": {
    "target": "ES2022", // Or higher
    "module": "ESNext",
    "lib": ["ES2022", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "Bundler", // Or Node16/NodeNext
    "experimentalDecorators": false, // Crucial: Disable legacy decorators
    "emitDecoratorMetadata": false, // Crucial: Disable legacy metadata
    "useDefineForClassFields": true // Recommended for class fields
  }
}
Advertisement

Use Case 1: Lightweight Dependency Injection Container

A zero-dependency DI container demonstrates class and field decorators for managing service lifecycles and injecting dependencies.

// di.ts

// A simple map to store registered services and their instances
const serviceRegistry = new Map<string | symbol, { constructor: new (...args: any[]) => any; instance?: any; singleton: boolean }>();

// Symbol for internal metadata storage on classes
const INJECTABLE_METADATA = Symbol('injectable_metadata');

/**
 * Class decorator to mark a class as injectable and register it with the DI container.
 * @param singleton If true, only one instance of the class will be created and reused.
 */
export function Injectable(singleton: boolean = true) {
  return <T extends new (...args: any[]) => any>(
    target: T,
    context: ClassDecoratorContext<T>
  ) => {
    if (context.kind !== 'class') {
      throw new Error(`@Injectable can only be applied to classes, not ${String(context.kind)}`);
    }

    const serviceName = target.name;
    if (serviceRegistry.has(serviceName)) {
      console.warn(`Service "${serviceName}" already registered. Overwriting.`);
    }

    serviceRegistry.set(serviceName, { constructor: target, singleton });

    // Store metadata about dependencies for this class
    context.addInitializer(function(this: T) {
      // This runs after the class is defined, but before any instances are created.
      // We can use 'this' to refer to the class constructor itself.
      // Any @Inject() decorators would have added metadata to this.
    });

    return target; // Return the original class
  };
}

/**
 * Property decorator to inject a dependency into a class field.
 * The field type annotation is used to determine the dependency to inject.
 */
export function Inject(targetService?: new (...args: any[]) => any) {
  return <T, V>(
    value: undefined, // Field decorators receive undefined as value
    context: ClassFieldDecoratorContext<T, V>
  ) => {
    if (context.kind !== 'field') {
      throw new Error(`@Inject can only be applied to fields, not ${String(context.kind)}`);
    }

    // This initializer runs when an instance of the class is created.
    context.addInitializer(function(this: T) {
      // 'this' refers to the instance of the class being constructed.
      // We need to determine the type of the field to inject.
      // In TypeScript, runtime type information for fields is not directly available
      // without emitDecoratorMetadata (which we're avoiding) or explicit type passing.
      // For simplicity, we'll rely on `targetService` if provided, or assume the field name
      // corresponds to a registered service name. A more robust solution might use a symbol
      // or string literal for the service key.

      const fieldName = String(context.name);
      let serviceKey: string | symbol;

      if (targetService) {
        serviceKey = targetService.name;
      } else {
        // Fallback: assume field name is the service name.
        // This is less robust and prone to naming conflicts.
        // A better approach would be to require @Inject(ServiceClass)
        serviceKey = fieldName.charAt(0).toUpperCase() + fieldName.slice(1); // e.g., 'logger' -> 'Logger'
        console.warn(`@Inject on field '${fieldName}' without explicit service. Assuming service name '${String(serviceKey)}'. Consider @Inject(ServiceClass).`);
      }

      const serviceEntry = serviceRegistry.get(serviceKey);

      if (!serviceEntry) {
        throw new Error(`Service "${String(serviceKey)}" not found in DI container for injection into field "${fieldName}".`);
      }

      if (serviceEntry.singleton && serviceEntry.instance) {
        // Reuse existing singleton instance
        (this as any)[fieldName] = serviceEntry.instance;
      } else {
        // Create new instance, resolving its dependencies recursively
        const instance = resolve(serviceEntry.constructor);
        if (serviceEntry.singleton) {
          serviceEntry.instance = instance; // Store for future reuse
        }
        (this as any)[fieldName] = instance;
      }
    });

    // Field decorators return a function to modify the initial value.
    // Since we're injecting, we don't need an initial value from the decorator.
    return;
  };
}

/**
 * Resolves an instance of a service from the DI container.
 * Handles recursive dependency resolution.
 */
export function resolve<T>(constructor: new (...args: any[]) => T): T {
  const serviceName = constructor.name;
  const serviceEntry = serviceRegistry.get(serviceName);

  if (!serviceEntry) {
    throw new Error(`Service "${serviceName}" not registered with DI container.`);
  }

  if (serviceEntry.singleton && serviceEntry.instance) {
    return serviceEntry.instance as T;
  }

  // Recursively resolve constructor arguments
  // This is a simplified approach. A real DI container would inspect constructor
  // parameters for @Inject annotations or type metadata.
  // For this example, we assume constructor args are also Injectable services
  // or primitive types not managed by DI.
  const dependencies: any[] = []; // In a real system, this would be populated by inspecting constructor params

  const instance = new constructor(...dependencies);

  // The @Inject field decorators will run their initializers *after* the constructor completes
  // and before the instance is fully returned from `new constructor()`.
  // This is handled by the JS engine's class construction process.

  if (serviceEntry.singleton) {
    serviceEntry.instance = instance;
  }

  return instance;
}

// --- Usage Example ---

interface ILogger {
  log(message: string): void;
}

@Injectable(true)
class ConsoleLogger implements ILogger {
  log(message: string): void {
    console.log(`[ConsoleLogger] ${message}`);
  }
}

@Injectable(false) // Not a singleton
class TransientService {
  private id: number;
  constructor() {
    this.id = Math.random();
    console.log(`TransientService instance created: ${this.id}`);
  }

  getId(): number {
    return this.id;
  }
}

@Injectable(true)
class UserService {
  @Inject(ConsoleLogger) // Explicitly inject ConsoleLogger
  private logger!: ILogger; // Use definite assignment assertion

  @Inject() // Implicitly inject TransientService (by field name convention)
  private transientService!: TransientService;

  getUser(id: string): string {
    this.logger.log(`Fetching user ${id}`);
    return `User ${id} (Transient ID: ${this.transientService.getId()})`;
  }
}

// Resolve the root service
const userService = resolve(UserService);
console.log(userService.getUser('123'));

const anotherUserService = resolve(UserService);
console.log(anotherUserService.getUser('456'));

// Verify singleton behavior for UserService and ConsoleLogger
console.log('userService === anotherUserService:', userService === anotherUserService); // Should be true
// Verify transient behavior for TransientService
// The transientService injected into userService and anotherUserService should be different instances
// This is because @Inject() creates a new instance for each injection point if the service is not a singleton.
// However, since UserService itself is a singleton, the same TransientService instance will be injected
// into the *same* UserService instance. To see different TransientService instances, we'd need to
// resolve UserService multiple times if it were *not* a singleton, or inject TransientService into
// different non-singleton services.
// Let's demonstrate by resolving TransientService directly:
const ts1 = resolve(TransientService);
const ts2 = resolve(TransientService);
console.log('ts1 === ts2:', ts1 === ts2); // Should be false, as TransientService is not a singleton

// Expected output:
// TransientService instance created: 0.xxxx
// TransientService instance created: 0.yyyy
// [ConsoleLogger] Fetching user 123
// User 123 (Transient ID: 0.xxxx)
// [ConsoleLogger] Fetching user 456
// User 456 (Transient ID: 0.xxxx)
// userService === anotherUserService: true
// TransientService instance created: 0.zzzz
// TransientService instance created: 0.aaaa
// ts1 === ts2: false

This DI container uses @Injectable to register classes and @Inject to mark fields for dependency injection. The addInitializer callback within the field decorator is crucial: it runs after the class constructor but before the instance is fully returned, allowing the field to be populated with the resolved dependency.

Use Case 2: High-Throughput Runtime Schema Validation

Runtime validation is critical for API boundaries and data integrity. Decorators can automate schema application and validation. We'll use a simplified validation library.

// validator.ts

type ValidatorFn = (value: any) => string | null; // Returns error message or null if valid

const VALIDATION_METADATA = Symbol('validation_metadata');

interface FieldValidation {
  propertyName: string | symbol;
  validators: ValidatorFn[];
}

/**
 * Stores validation rules on the class prototype.
 */
function addValidationRule(target: Object, propertyName: string | symbol, validator: ValidatorFn) {
  if (!Reflect.hasOwnMetadata(VALIDATION_METADATA, target)) {
    Reflect.defineMetadata(VALIDATION_METADATA, [], target);
  }
  const validations = Reflect.getOwnMetadata(VALIDATION_METADATA, target) as FieldValidation[];

  let fieldValidation = validations.find(fv => fv.propertyName === propertyName);
  if (!fieldValidation) {
    fieldValidation = { propertyName, validators: [] };
    validations.push(fieldValidation);
  }
  fieldValidation.validators.push(validator);
}

/**
 * Decorator factory for 'required' validation.
 */
export function Required() {
  return <T, V>(
    value: undefined,
    context: ClassFieldDecoratorContext<T, V>
  ) => {
    if (context.kind !== 'field') {
      throw new Error(`@Required can only be applied to fields, not ${String(context.kind)}`);
    }
    context.addInitializer(function(this: T) {
      addValidationRule(Object.getPrototypeOf(this), context.name, (val) =>
        val === null || val === undefined || (typeof val === 'string' && val.trim() === '')
          ? `${String(context.name)} is required.`
          : null
      );
    });
  };
}

/**
 * Decorator factory for 'minLength' validation.
 */
export function MinLength(length: number) {
  return <T, V extends string>(
    value: undefined,
    context: ClassFieldDecoratorContext<T, V>
  ) => {
    if (context.kind !== 'field') {
      throw new Error(`@MinLength can only be applied to fields, not ${String(context.kind)}`);
    }
    context.addInitializer(function(this: T) {
      addValidationRule(Object.getPrototypeOf(this), context.name, (val) =>
        typeof val === 'string' && val.length < length
          ? `${String(context.name)} must be at least ${length} characters long.`
          : null
      );
    });
  };
}

/**
 * Decorator factory for 'max' validation.
 */
export function Max(maxValue: number) {
  return <T, V extends number>(
    value: undefined,
    context: ClassFieldDecoratorContext<T, V>
  ) => {
    if (context.kind !== 'field') {
      throw new Error(`@Max can only be applied to fields, not ${String(context.kind)}`);
    }
    context.addInitializer(function(this: T) {
      addValidationRule(Object.getPrototypeOf(this), context.name, (val) =>
        typeof val === 'number' && val > maxValue
          ? `${String(context.name)} must be at most ${maxValue}.`
          : null
      );
    });
  };
}

/**
 * Class decorator to enable validation for a class.
 * It adds a `validate()` method to the class prototype.
 */
export function Validatable() {
  return <T extends new (...args: any[]) => any>(
    target: T,
    context: ClassDecoratorContext<T>
  ) => {
    if (context.kind !== 'class') {
      throw new Error(`@Validatable can only be applied to classes, not ${String(context.kind)}`);
    }

    // Add a validate method to the class prototype
    context.addInitializer(function(this: T) {
      Object.defineProperty(this.prototype, 'validate', {
        value: function(this: any): string[] {
          const errors: string[] = [];
          const validations = Reflect.getOwnMetadata(VALIDATION_METADATA, Object.getPrototypeOf(this)) as FieldValidation[] || [];

          for (const fieldValidation of validations) {
            const value = this[fieldValidation.propertyName];
            for (const validator of fieldValidation.validators) {
              const error = validator(value);
              if (error) {
                errors.push(error);
              }
            }
          }
          return errors;
        },
        writable: true,
        configurable: true,
      });
    });

    return target;
  };
}

// Polyfill for Reflect.metadata if not available (e.g., in some environments or older TS versions)
// For native decorators, Reflect.metadata is not strictly required by the spec itself,
// but it's a common pattern for storing metadata.
// If you're using a bundler like Webpack/Rollup/ESBuild, ensure 'reflect-metadata' is imported once at the entry point.
// `import 'reflect-metadata';`
// For this example, we'll assume it's available or provide a minimal shim.
if (typeof Reflect === 'undefined' || !Reflect.hasOwnMetadata) {
  console.warn("Reflect.metadata not found. Providing a minimal shim. For production, consider 'reflect-metadata' polyfill.");
  const metadataMap = new WeakMap<object, Map<string | symbol, any>>();

  (Reflect as any).defineMetadata = (key: string | symbol, value: any, target: object, propertyKey?: string | symbol) => {
    let targetMetadata = metadataMap.get(target);
    if (!targetMetadata) {
      targetMetadata = new Map();
      metadataMap.set(target, targetMetadata);
    }
    const metadataKey = propertyKey ? `${String(propertyKey)}:${String(key)}` : String(key);
    targetMetadata.set(metadataKey, value);
  };

  (Reflect as any).getOwnMetadata = (key: string | symbol, target: object, propertyKey?: string | symbol) => {
    const targetMetadata = metadataMap.get(target);
    if (!targetMetadata) return undefined;
    const metadataKey = propertyKey ? `${String(propertyKey)}:${String(key)}` : String(key);
    return targetMetadata.get(metadataKey);
  };

  (Reflect as any).hasOwnMetadata = (key: string | symbol, target: object, propertyKey?: string | symbol) => {
    const targetMetadata = metadataMap.get(target);
    if (!targetMetadata) return false;
    const metadataKey = propertyKey ? `${String(propertyKey)}:${String(key)}` : String(key);
    return targetMetadata.has(metadataKey);
  };
}


// --- Usage Example ---

@Validatable()
class Product {
  @Required()
  @MinLength(3)
  name: string;

  @Required()
  @Max(9999)
  price: number;

  description?: string;

  constructor(name: string, price: number, description?: string) {
    this.name = name;
    this.price = price;
    this.description = description;
  }
}

// Test cases
const product1 = new Product('Laptop', 1200);
const errors1 = (product1 as any).validate();
console.log('Product 1 errors:', errors1); // Expected: []

const product2 = new Product('', 50000);
const errors2 = (product2 as any).validate();
console.log('Product 2 errors:', errors2); // Expected: ["name is required.", "name must be at least 3 characters long.", "price must be at most 9999."]

const product3 = new Product('TV', 10000);
const errors3 = (product3 as any).validate();
console.log('Product 3 errors:', errors3); // Expected: ["price must be at most 9999."]

const product4 = new Product('A', 100);
const errors4 = (product4 as any).validate();
console.log('Product 4 errors:', errors4); // Expected: ["name must be at least 3 characters long."]

// Demonstrate that validate method is added to prototype
console.log('Product.prototype has validate method:', 'validate' in Product.prototype); // true

This validation system uses field decorators (@Required, @MinLength, @Max) to attach validation rules to properties. The @Validatable class decorator then injects a validate method onto the class prototype. This method iterates through the collected rules and executes them against the instance's properties. The addInitializer in field decorators ensures that validation rules are registered on the class prototype before any instances are created, making the validate method available immediately. Note the use of Reflect.defineMetadata and Reflect.getOwnMetadata for storing validation rules, which requires the reflect-metadata polyfill (or a shim as provided).

Architectural Comparison: Stage 3 Decorators vs. Legacy experimentalDecorators

FeatureStage 3 Decorators (TS 5.8+)Legacy experimentalDecorators (TS < 5.8)
Specification StatusECMAScript Stage 3 (near final)Non-standard, experimental
tsconfig.jsonexperimentalDecorators: false, emitDecoratorMetadata: falseexperimentalDecorators: true, emitDecoratorMetadata: true (for DI)
Decorator ContextRich ClassDecoratorContext, ClassMethodDecoratorContext, etc.No explicit context object; arguments are target, key, descriptor
addInitializerYes, allows running code after class definition or after instance constructionNo direct equivalent; often required manual static property setup
Field DecoratorsReceive undefined as value, return initializer functionReceive target, key; modify descriptor (if accessor) or target
Private FieldsCan decorate and interact with private fields via accessCannot decorate private fields
Reflect.metadataNot inherently required by spec; used for custom metadataHeavily relied upon for type reflection (e.g., for DI)
Runtime BehaviorDecorators run during class definitionDecorators run during class definition
PerformanceGenerally optimized, part of JS engine runtimeCan introduce overhead due to Reflect.metadata and polyfills
MaintainabilityStandardized API, better long-term stabilityAPI subject to change, less predictable
Advertisement

Production Gotchas & Troubleshooting

  1. experimentalDecorators Still Enabled:

    • Symptom: Decorators behave unexpectedly, or TypeScript complains about decorator syntax even with TS 5.8+.
    • Cause: You likely still have "experimentalDecorators": true in your tsconfig.json.
    • Fix: Set "experimentalDecorators": false and "emitDecoratorMetadata": false. Ensure your target is ES2022 or higher and moduleResolution is Bundler or NodeNext.
  2. Reflect.metadata Missing:

    • Symptom: Runtime errors like Reflect.defineMetadata is not a function when using metadata patterns (like in the validation example).
    • Cause: The reflect-metadata polyfill is not imported or not available in your runtime environment. Native Stage 3 decorators do not inherently provide Reflect.metadata.
    • Fix: Add import 'reflect-metadata'; at the very top of your application's entry point. Ensure the reflect-metadata package is installed (npm install reflect-metadata).
  3. Type Information at Runtime:

    • Symptom: In DI, you want to inject ServiceA into class MyClass { @Inject() serviceA: ServiceA; } but the decorator cannot determine ServiceA's type.
    • Cause: TypeScript types are erased at compile time. emitDecoratorMetadata (which provides type info via Reflect.metadata) is for legacy decorators and is incompatible with Stage 3.
    • Fix: Explicitly pass the type to the decorator, e.g., @Inject(ServiceA). For constructor injection, you'd need to parse the constructor's Function.prototype.toString() or use a build-time step to extract types.
  4. Decorator Order of Execution:

    • Symptom: Unexpected behavior when multiple decorators are applied to the same target.
    • Cause: Decorators are applied in a specific order:
      • Field/Method/Accessor: From top to bottom, then evaluated from bottom to top.
      • Class: Applied after all its members are decorated.
      • addInitializer callbacks run in the order they were added.
    • Fix: Understand the execution order. If decorator A depends on decorator B's output, ensure B runs first. For addInitializer, they run in the order they were registered.
  5. this Context in addInitializer:

    • Symptom: this inside addInitializer refers to the wrong object.
    • Cause: For ClassDecoratorContext, this refers to the class constructor. For ClassFieldDecoratorContext, ClassMethodDecoratorContext, etc., this refers to the instance being constructed.
    • Fix: Be mindful of the this context. For class-level operations (like adding a prototype method), use Object.getPrototypeOf(this) or this.prototype if this is the constructor. For instance-level operations (like setting a field value), this directly refers to the instance.

Frequently Asked Questions

  1. Can I use Stage 3 Decorators with React/Angular/Vue? Yes. Modern frameworks are adapting. Angular 17+ fully supports Stage 3 decorators. React and Vue don't inherently use decorators for their core components but can benefit from them in utility classes or services. Ensure your build tooling (Webpack, Vite, etc.) is configured to handle them (e.g., Babel with @babel/plugin-proposal-decorators in 2023-11 mode).

  2. Are Stage 3 Decorators slower than regular functions? Decorators execute at class definition time, not at runtime for every instance creation (except for addInitializer callbacks that run during instance construction). The overhead is minimal and typically negligible for most applications. The primary performance impact comes from what the decorators do (e.g., complex reflection or heavy computations).

  3. How do I debug decorators? Place debugger; statements inside your decorator functions. Since they run during class definition, your debugger will pause when the class is being defined. For addInitializer callbacks, they will pause during instance creation.

  4. Can I decorate constructor parameters? The current Stage 3 Decorator proposal does not include parameter decorators. This was a feature of experimentalDecorators but was removed due to complexity and concerns about runtime type reflection. For DI with constructor parameters, you typically need to explicitly provide dependencies or use a build-time step to extract type information.

  5. What's the difference between value and context in decorator arguments? value is the thing being decorated (e.g., the method function, the class constructor). For field decorators, value is undefined because fields don't have an initial "value" in the same way methods do. context provides metadata about the decorated element, such as its kind (class, method, field), name, static status, private status, and the crucial addInitializer function.

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement