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.
Table of Contents
- 1. What decorators actually are and when they run
- 2. TC39 Stage 3 decorators versus the legacy system
- 3. Syntax differences: the context API versus target and descriptor
- 4. Practical example: an @log method decorator with the new context API
- 5. Property decorator: @readonly and access control
- 6. Decorator factories: parameterized decorators like @validate(schema)
- 7. Dependency injection: how Angular and NestJS use decorators
- 8. Watch out for overuse: debugging, stack traces, and readability
- 9. Migration and comparison: legacy decorators versus TC39 Stage 3
- 10. Summary
- 11. FAQ
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.