Decorators in TypeScript: Fundamentals and Use Cases
AI generated
<T>
type
TypeScript · Decorators · TC39 · Metaprogramming
Decorators in TypeScript: Fundamentals and Use Cases
From TC39 Stage 3 to dependency injection in Angular and NestJS

Decorators extend classes, methods, and properties with reusable behavior without touching the underlying code. Since TypeScript 5.0, the new TC39 Stage 3 standard is available with no compiler flag, while Angular and older NestJS versions still rely on experimentalDecorators and reflect-metadata. This article walks through both systems with real code examples for logging, validation, and dependency injection.

12 min. read TC39 Stage 3 · experimentalDecorators · reflect-metadata TypeScript 5 · Angular · NestJS

1. What decorators actually are and when they run

A decorator is syntactically a function written with the @ symbol in front of a class, method, property, or accessor, and it runs exactly once when the module loads, not on every instantiation of an object. This execution timing is the key difference from regular method calls: a decorator runs as soon as the class is defined, and it can replace, wrap, or augment the declaration itself before any object has even been created.

In practice this means: an @log decorator on a method wraps the original function in a new function that runs additional code before and after the actual call, for example a log statement with the passed arguments. Because this replacement happens once at definition time, there is no runtime overhead per call beyond the wrapper itself. This property makes decorators ideal for cross-cutting concerns like logging, caching, validation, or access control, which would otherwise only be achievable through repeated boilerplate in every single method.

2. TC39 Stage 3 decorators versus the legacy system

Up through TypeScript 4.x there was only a single decorator implementation, enabled via the compiler flag experimentalDecorators, based on a very early TC39 proposal that differed from the final ECMAScript specification. With TypeScript 5.0, the TC39 Stage 3 proposal for decorators was implemented natively, with no compiler flag required at all. This new standard follows the actual JavaScript specification and will eventually run in native JavaScript engines without TypeScript, once browsers and Node.js fully support it.

Still, the legacy system is far from gone: Angular continues to rely on experimentalDecorators combined with emitDecoratorMetadata and the reflect-metadata library to read type information for dependency injection at runtime. Older NestJS projects that haven't yet migrated to the new decorators also still need this combination. Teams starting a new project should generally stick with the modern standard and skip the flag, while teams maintaining an existing Angular or NestJS project need to keep the legacy configuration until the frameworks themselves fully migrate.


{
  "compilerOptions": {
    // Legacy system: required for Angular and older NestJS versions
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,

    // Modern TC39 Stage 3 decorators need neither flag above.
    // Simply omit both options and use TypeScript 5.0 or newer,
    // the compiler applies the standard decorator semantics by default.
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true
  }
}

3. Syntax differences: the context API versus target and descriptor

A legacy method decorator receives three parameters: target (the prototype or constructor), propertyKey (the method name as a string or symbol), and descriptor (the PropertyDescriptor object with value, writable, enumerable, and configurable). To change behavior, the decorator must manually overwrite descriptor.value and return the modified descriptor object, or return undefined if descriptor.value was mutated directly. This signature is loosely typed, since target can take different shapes depending on the kind of declaration, leaving TypeScript little room to offer real type safety.

The new TC39 Stage 3 decorators instead receive two parameters: the value being decorated itself, for example the method as a function, and a ClassMethodDecoratorContext object with clearly typed fields such as kind, name, static, private, and the addInitializer method for code that should only run when an instance is created. The decorator either returns nothing or a new function that replaces the original value. This signature is fully typed and defined independently in the TypeScript compiler for every decorator kind: class, method, field, and accessor.

4. Practical example: an @log method decorator with the new context API

An @log decorator demonstrates the new system nicely: it takes the original method, produces a replacement function that logs the arguments and return value, and returns that function. The context parameter provides the method name for the log output, without needing to manually convert propertyKey into a string. Because TypeScript checks the decorator's return value precisely against the original signature, a typo in the wrapper function fails at compile time instead of producing a cryptic runtime error later.

For cases where a decorator needs additional per-instance state, for example a call counter, context.addInitializer provides a callback that runs inside the constructor of every new instance and can access this. This replaces the WeakMap workaround that legacy decorators often needed to manage instance data outside the actual class. In practice this significantly reduces the code needed for stateful decorators and makes it much easier to follow.


// Modern TC39 Stage 3 method decorator, no compiler flag needed
function log<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
  const methodName = String(context.name);

  function replacementMethod(this: This, ...args: Args): Return {
    console.log(`[LOG] Calling ${methodName} with`, args);
    const result = target.call(this, ...args);
    console.log(`[LOG] ${methodName} returned`, result);
    return result;
  }

  return replacementMethod;
}

class OrderService {
  @log
  placeOrder(orderId: string, quantity: number): string {
    return `Order ${orderId} placed with quantity ${quantity}`;
  }
}

const service = new OrderService();
service.placeOrder("A-1001", 3);
// [LOG] Calling placeOrder with [ 'A-1001', 3 ]
// [LOG] placeOrder returned Order A-1001 placed with quantity 3

5. Property decorator: @readonly and access control

Property decorators in the new system differ fundamentally from method decorators, because plain class fields don't have a property descriptor with a replaceable value. For fields whose access needs to be intercepted, the accessor keyword is used instead, which lets TypeScript automatically generate a hidden getter and setter. An @readonly decorator on such an auto-accessor can replace the generated setter with a function that throws an exception on every write attempt after initialization.

Plain class fields without the accessor keyword instead receive an initializer function, which the decorator can wrap to transform the starting value, for example trimming or normalizing input data before it lands in the field. Legacy property decorators, by contrast, could only intervene in initialization in a very limited way, since they only had access to target and propertyKey, with no connection to the actual initial value. The new context API closes this gap completely and makes declarative validation directly on the field practical.


// Auto-accessor decorator: block writes after initialization
function readonly<This, Value>(
  target: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext<This, Value>
): ClassAccessorDecoratorResult<This, Value> {
  return {
    get(this: This): Value {
      return target.get.call(this);
    },
    set(this: This, newValue: Value): void {
      throw new Error(`Cannot assign to read only property ${String(context.name)}`);
    },
    init(this: This, initialValue: Value): Value {
      return initialValue;
    },
  };
}

class ApiConfig {
  @readonly
  accessor baseUrl: string = "https://api.mironsoft.de/v1";
}

const config = new ApiConfig();
console.log(config.baseUrl); // https://api.mironsoft.de/v1
config.baseUrl = "https://evil.example.com"; // throws Error

6. Decorator factories: parameterized decorators like @validate(schema)

A plain decorator like @log doesn't take arguments of its own, but many real-world use cases need configuration, for example a validation schema or a cache timeout. The solution is a decorator factory: an ordinary function that takes the desired arguments and itself returns a decorator. This extra layer of indirection doesn't change the underlying mechanics; every decorator it returns still follows the context API signature exactly, but it allows the same decorator type to be reused with different configuration throughout the same project.

A @validate(schema) decorator, for instance, checks the passed arguments against a schema before every method call, using Zod or a custom validation function, and throws a meaningful error on violations before the actual method code even runs. This pattern prevents validation logic from being scattered and duplicated across many methods, and makes validation rules readable in a single, clearly visible place directly above the method signature, instead of hiding them somewhere inside the method body.


// Decorator factory: a function that returns a configured decorator
interface Schema<T> {
  parse(value: unknown): T;
}

function validate<This, Args extends unknown[], Return>(schema: Schema<Args[0]>) {
  return function (
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
  ) {
    return function (this: This, ...args: Args): Return {
      schema.parse(args[0]);
      return target.call(this, ...args);
    };
  };
}

const orderSchema: Schema<{ quantity: number }> = {
  parse(value) {
    const input = value as { quantity: number };
    if (input.quantity <= 0) {
      throw new Error("quantity must be greater than zero");
    }
    return input;
  },
};

class Checkout {
  @validate(orderSchema)
  submit(order: { quantity: number }): void {
    console.log("Order submitted", order);
  }
}

7. Dependency injection: how Angular and NestJS use decorators

Angular and NestJS use decorators not just for code organization but as the load-bearing foundation of their dependency injection containers. @Injectable marks a class as manageable by the DI container, @Component or @Controller registers metadata about selectors or routes, and constructor parameter decorators like @Inject mark which dependency should be injected at which position. All of this information is stored and read via reflect-metadata at runtime, as soon as the container needs to create an instance of the class.

This runtime reflection is exactly why Angular and older NestJS versions still need experimentalDecorators together with emitDecoratorMetadata: only that combination makes the TypeScript compiler emit design:paramtypes metadata, which the container uses to derive constructor types for resolving dependencies. The new TC39 Stage 3 decorators don't yet support parameter decorators or automatic type metadata emission in the same form, which is why a full migration of these frameworks to the new standard is technically involved and hasn't happened across the board yet.


// NestJS-style legacy decorators, backed by reflect-metadata
import "reflect-metadata";

@Injectable()
class ProductService {
  findAll(): string[] {
    return ["Product A", "Product B"];
  }
}

@Controller("products")
class ProductController {
  // Parameter decorator marks the constructor dependency for the DI container
  constructor(private readonly productService: ProductService) {}

  @Get()
  list(): string[] {
    return this.productService.findAll();
  }
}

// At bootstrap time, Nest reads design:paramtypes metadata emitted by
// emitDecoratorMetadata to resolve ProductService and inject the instance.

8. Watch out for overuse: debugging, stack traces, and readability

Every additional decorator adds another layer of indirection between the call site and the actual execution. For a single method with one @log decorator that's harmless, but classes with five or more stacked decorators, some of which affect each other depending on order, quickly become hard to follow. Stack traces on errors then contain extra wrapper frames that obscure the real origin of the failure, and a debugger has to step through several generated intermediate functions before reaching the actual method code.

The execution order of stacked decorators also follows a rule that isn't always intuitive: decorator expressions are evaluated top to bottom, but the resulting functions are applied bottom to top, similar to nested function calls. Teams should therefore use decorators deliberately for clearly scoped cross-cutting concerns, such as logging or validation, and not as a generic tool for arbitrary business logic. A decorator whose effect can't be explained in a single sentence is usually a candidate for an ordinary, explicitly called function instead.

9. Migration and comparison: legacy decorators versus TC39 Stage 3

Teams looking to migrate from experimentalDecorators to the new standard should first check whether all the libraries they use are already compatible. Frameworks that depend on parameter decorators and reflect-metadata, such as Angular or NestJS, currently can't be switched over to the new decorators without significant effort, while custom utility decorators like @log, @readonly, or @validate are usually straightforward to port. The table below compares both systems across the most important decision dimensions for a migration project.

Aspect Legacy: experimentalDecorators Modern: TC39 Stage 3
Compiler flag experimentalDecorators: true is mandatory No flag, default behavior since TypeScript 5.0
Metadata reflection Requires emitDecoratorMetadata plus the reflect-metadata package Context object provides metadata natively, no external package
Framework support Required for Angular and older NestJS versions Growing, but DI frameworks are migrating only gradually
Type safety of the signature target: any, loosely typed Fully typed via ClassMethodDecoratorContext and friends
Standardization status Proprietary TypeScript proposal, never standardized TC39 Stage 3, part of the upcoming ECMAScript specification

In practice this means: new projects with no Angular or NestJS dependency should adopt TC39 Stage 3 decorators without a compiler flag from the start. Existing Angular or NestJS codebases stay on the legacy system for now, until the respective frameworks officially migrate themselves, something already announced for both ecosystems but not yet complete. Mixing both systems within the same tsconfig isn't technically supported, which is why the decision needs to be made consistently across the whole project.

Mironsoft

TypeScript tooling, build scripts, and headless integrations for Magento and Hyvä

TypeScript code that stays maintainable instead of sprawling?

We analyze existing TypeScript codebases, migrate from experimentalDecorators to the TC39 Stage 3 standard, and build clean, type-safe decorator patterns for logging, validation, and dependency injection in your stack.

Code review

Analysis of existing decorator patterns and tsconfig configuration

Migration

Step-by-step move from experimentalDecorators to TC39 Stage 3

Headless tooling

TypeScript build scripts and API clients for Magento integrations

10. Summary

Decorators in TypeScript solve a recurring problem more elegantly than classic inheritance or manual wrapping: they add behavior to classes, methods, properties, and accessors at definition time without touching the underlying code. Since TypeScript 5.0, the TC39 Stage 3 standard is available with no compiler flag at all, gradually replacing the old, proprietary system. Anyone starting a new project today should consistently rely on the modern context API instead of enabling experimentalDecorators.

At the same time, the legacy system remains relevant as long as Angular and older NestJS versions depend on reflect-metadata and parameter decorators for their dependency injection containers. Practical use cases like logging, validation, access control, and dependency injection benefit from the same underlying idea in both systems: defining recurring behavior in one central place instead of duplicating it in every single method. Using decorators deliberately and sparingly earns readable, maintainable code; overusing them buys hard-to-debug layers of indirection.

Decorators in TypeScript, The Essentials at a Glance

Execution timing

Decorators run at class definition time, not at instantiation, so there is no runtime overhead per object.

TC39 Stage 3 standard

Available since TypeScript 5.0 with no compiler flag, with a typed context API for classes, methods, fields, and accessors.

Legacy still needed for DI

Angular and older NestJS versions require experimentalDecorators, emitDecoratorMetadata, and reflect-metadata.

Use sparingly

Stacked decorators make debugging and stack traces harder, so use them only for clearly scoped cross-cutting concerns.

11. FAQ: Decorators in TypeScript

1What is a decorator in TypeScript?
A function using @ syntax in front of a class, method, property, or accessor that adds behavior at definition time, without changing the original code.
2When exactly does a decorator run?
Once, at class definition time when the module loads, not on every instantiation of an object.
3TC39 Stage 3 versus experimentalDecorators?
TC39 Stage 3 is the final standard, available since TypeScript 5.0 with no flag. experimentalDecorators is the older system that was never standardized.
4Do I still need experimentalDecorators in TypeScript 5?
Only for frameworks like Angular or older NestJS versions with parameter-decorator-based dependency injection.
5Why do Angular and NestJS still use the old decorators?
Their DI containers read design:paramtypes metadata, which only emitDecoratorMetadata together with experimentalDecorators produces.
6What is reflect-metadata and what is it used for?
A library that attaches and reads back type information on classes at runtime, the foundation for DI containers like those in Angular or NestJS.
7How do I write my own decorator like @log?
The decorator takes the original method and a context object and returns a replacement function that runs extra code before and after the call.
8What is a decorator factory?
A function that takes its own arguments and itself returns a decorator, for example @validate(schema) with different configuration each time.
9Can decorators take arguments?
Not directly, only through a decorator factory that is configured and returns the actual decorator.
10What are the risks of overusing decorators?
Harder debugging, longer stack traces from extra wrapper frames, and an execution order that isn't always intuitive when several decorators are stacked.