Decorators in TypeScript: Class, Method & Property Decorators Explained
If you’ve used Angular, NestJS, or TypeORM, you’ve already seen decorators — @Component, @Injectable, @Entity — without necessarily knowing what they actually do under the hood. A decorator is a function that runs at class-definition time and can inspect, modify, or replace the thing it’s attached to. This guide covers how they actually work, why TypeScript currently has two different, incompatible decorator implementations, and which one you should use in a new project.
The Two Decorator Systems — Why This Is Confusing
TypeScript’s decorator support has an unusually messy history. It shipped an experimental implementation years before JavaScript’s own standards committee (TC39) had settled on an official decorator proposal — and when TC39’s design eventually diverged from TypeScript’s early implementation, TypeScript ended up supporting both, under different compiler settings.
// tsconfig.json — the OLDER, still widely-used implementation (Angular, NestJS, TypeORM all use this){ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true }}// tsconfig.json — the NEWER, TC39 Stage 3 standard implementation (TypeScript 5.0+, no flag needed){ "compilerOptions": { "target": "ES2022" // experimentalDecorators is NOT set — Stage 3 decorators are the default without it }}This matters practically, not just historically: a decorator written for one system will not work correctly under the other — the function signatures decorators receive are genuinely different between the two. If you’re working in an existing Angular or NestJS codebase, you’re using experimentalDecorators: true, full stop, because that’s what those frameworks were built against. If you’re starting a brand-new project with no legacy framework dependency forcing the older system, the Stage 3 standard (the default in TypeScript 5+ without the experimental flag) is the one aligned with where the language itself is headed.
Class Decorators (Stage 3 / Modern Syntax)
function logged<T extends new (...args: any[]) => any>(target: T, context: ClassDecoratorContext) { return class extends target { constructor(...args: any[]) { console.log(`Creating instance of ${context.name}`); super(...args); } };}
@loggedclass UserService { constructor(private apiUrl: string) {}}
new UserService("https://api.example.com");// Logs: "Creating instance of UserService"A class decorator receives the class itself (target) and a context object describing it, and can return a new class that replaces the original entirely — here, logged returns a subclass that logs before delegating to the real constructor via super(...args). This is genuinely powerful: the decorator doesn’t just observe the class, it can wrap or replace its behavior completely, which is exactly what dependency-injection frameworks use to register metadata about a class without the class’s own code needing to know anything about the framework.
Method Decorators
function measure(originalMethod: any, context: ClassMethodDecoratorContext) { const methodName = String(context.name);
return function (this: any, ...args: any[]) { const start = performance.now(); const result = originalMethod.call(this, ...args); console.log(`${methodName} took ${(performance.now() - start).toFixed(2)}ms`); return result; };}
class ReportGenerator { @measure generateReport(rows: number) { let total = 0; for (let i = 0; i < rows; i++) total += i; return total; }}
new ReportGenerator().generateReport(1_000_000);// Logs: "generateReport took 3.42ms" (actual number varies)measure wraps the original method, timing how long it takes and logging the result — the decorated method’s actual callers (new ReportGenerator().generateReport(...)) never need to know timing logic exists at all. This is the classic decorator use case: cross-cutting concerns (logging, timing, caching, authorization checks) applied declaratively at the point a method is defined, instead of duplicated inline inside every method that needs them.
Property Decorators
function required(target: undefined, context: ClassFieldDecoratorContext) { return function (this: any, initialValue: any) { if (initialValue === undefined || initialValue === null) { throw new Error(`Property '${String(context.name)}' is required`); } return initialValue; };}
class Product { @required name: string;
constructor(name: string) { this.name = name; }}
new Product("Widget"); // finenew Product(undefined as any); // throws: "Property 'name' is required"A field decorator can intercept and transform the value being assigned during initialization — here, required validates that the initial value isn’t null/undefined before allowing the assignment through, catching a missing-required-field mistake immediately at construction rather than later when something tries to use the unexpectedly empty field.
Legacy Decorators (experimentalDecorators) — What Angular/NestJS Actually Use
// This syntax requires "experimentalDecorators": true — different signature from Stage 3 abovefunction LegacyLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (...args: any[]) { console.log(`Calling ${propertyKey}`); return original.apply(this, args); }; return descriptor;}
class Service { @LegacyLog doWork() { console.log("Working..."); }}Note the different parameters: (target, propertyKey, descriptor) instead of Stage 3’s (originalMethod, context). This is the exact incompatibility mentioned earlier — the two systems pass fundamentally different arguments to the decorator function, so code written for one cannot simply be recompiled under the other’s flag without rewriting the decorator’s own implementation.
Real framework decorators, recognized by pattern
// NestJS (uses legacy decorators + reflect-metadata)@Injectable()class UserService { constructor(private readonly userRepository: UserRepository) {}}
@Controller("users")class UserController { constructor(private readonly userService: UserService) {}
@Get(":id") findOne(@Param("id") id: string) { return this.userService.findById(id); }}@Injectable(), @Controller(), @Get(), and @Param() are all ordinary decorators built on experimentalDecorators, combined with a library called reflect-metadata that lets a decorator inspect a parameter or property’s declared type at runtime (something JavaScript itself has no built-in way to do, since types are erased — reflect-metadata works by having emitDecoratorMetadata emit a small amount of extra type information as actual runtime data specifically to support this). This combination is what lets NestJS’s dependency injection container automatically figure out that UserController’s constructor needs a UserService instance, purely by inspecting the parameter’s declared type at runtime.
Decorator Factories — Decorators That Take Arguments
function retry(times: number) { return function (originalMethod: any, context: ClassMethodDecoratorContext) { return async function (this: any, ...args: any[]) { let lastError: unknown; for (let attempt = 0; attempt < times; attempt++) { try { return await originalMethod.call(this, ...args); } catch (err) { lastError = err; } } throw lastError; }; };}
class ApiClient { @retry(3) async fetchData(url: string) { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }}retry(3) is a decorator factory — a function that returns the actual decorator, which lets the decorator accept configuration (3 retries here) rather than being fixed at a single hard-coded behavior. This is exactly the same nested-function pattern used for parameterized decorators in Python or annotations with arguments in Java, applied to TypeScript’s class/method decorator system.
When to Reach for Decorators (and When Not To)
Good fits: cross-cutting concerns applied uniformly across many classes/methods — logging, timing, retry logic, validation, dependency injection registration, ORM entity/column mapping (@Entity(), @Column() in TypeORM). These all share a pattern: the same wrapping behavior, applied declaratively, without touching each method’s actual business logic.
Poor fits: one-off logic specific to a single method that isn’t reused elsewhere — a decorator adds indirection (the actual behavior lives in the decorator function, not visibly inline where it’s used), which is worth the cost only when that indirection is genuinely reused across many places. A single if statement inline in one method is clearer than a custom decorator built and used exactly once.
Accessor Decorators
function logAccess(target: any, context: ClassGetterDecoratorContext) { return function (this: any) { const value = target.call(this); console.log(`Reading ${String(context.name)}: ${value}`); return value; };}
class Temperature { #celsius = 20;
@logAccess get celsius() { return this.#celsius; }}
new Temperature().celsius; // Logs: "Reading celsius: 20", then returns 20Accessor (getter/setter) decorators follow the same shape as method decorators — wrapping the original getter function — and are useful for adding logging, caching, or validation specifically around computed property access, distinct from decorating a plain data field (which uses the field decorator shape shown earlier).
Applying Multiple Decorators
class ReportService { @measure @retry(3) async generateMonthlyReport(month: string) { return fetchReportData(month); }}Multiple decorators on the same target apply in a specific, easy-to-get-backwards order: they’re evaluated top-to-bottom but applied (wrapped) bottom-to-top — so generateMonthlyReport is first wrapped by retry(3), and the result of that wrapping is then wrapped again by measure. In practice this means measure here times the entire retrying operation (including all attempts), not just a single attempt — worth tracing through deliberately any time decorator ordering affects what’s actually being measured or wrapped.
Common Pitfalls
| Symptom | Likely cause |
|---|---|
| ”Unable to resolve signature of class decorator” | Mixing Stage 3 decorator syntax with experimentalDecorators: true still enabled in tsconfig — the two systems have incompatible signatures |
| A NestJS/Angular decorator throws a confusing type error | experimentalDecorators and/or emitDecoratorMetadata missing from tsconfig.json — these frameworks require both |
Decorated method loses its correct this binding | The wrapping function inside the decorator uses an arrow function instead of a regular function with an explicit this parameter, or fails to .call(this, ...) when invoking the original |
Metadata-based DI (reflect-metadata) silently injects undefined | emitDecoratorMetadata isn’t enabled, so the type information the DI container relies on was never emitted at compile time |
| Decorators seem to run in an unexpected order | Remember: evaluated top-to-bottom, applied (wrapped) bottom-to-top — trace multi-decorator stacks carefully |
Frequently Asked Questions
Which decorator system should a brand-new project use? The Stage 3 standard (no experimentalDecorators flag needed, default in TypeScript 5+) unless you’re specifically building on a framework — Angular, NestJS, TypeORM, and most existing dependency-injection-heavy frameworks — that requires the legacy experimentalDecorators system. Check your framework’s documentation; this isn’t optional once a framework has a hard requirement.
Can I mix both decorator systems in one project? No — experimentalDecorators is a single project-wide compiler flag; you can’t use the legacy syntax in some files and the Stage 3 syntax in others within the same compilation.
What is reflect-metadata actually doing? It’s a polyfill implementing a metadata reflection API, combined with emitDecoratorMetadata (a compiler flag that emits extra runtime type information specifically for decorators to read). Together they let a decorator function inspect a parameter’s or property’s declared TypeScript type at runtime — information that would otherwise be completely erased, per how TypeScript works. This is genuinely one of the very few places where TypeScript’s type information leaks into runtime behavior at all, and it only happens because of this specific, deliberate opt-in mechanism.
Do decorators have a runtime performance cost? Yes, generally small but nonzero — a method decorator wraps the original method in another function call, and metadata reflection adds a small amount of runtime bookkeeping. For typical application code (DI container setup, occasional logging/timing wrappers), this cost is negligible; it becomes worth measuring only in extremely hot code paths calling a heavily-decorated method millions of times.
Are decorators unique to TypeScript? No — they’re a genuine (if still-evolving) JavaScript language feature via the TC39 Stage 3 proposal, meaning plain JavaScript will eventually support the modern decorator syntax natively, independent of TypeScript. TypeScript’s role is providing type-checking on top of a feature the underlying language itself is adopting.
Series Wrap-Up
This closes the TypeScript series — from type erasure and structural typing at the foundation, through the full type system (interfaces, unions, generics, mapped and conditional types), into the object-oriented and tooling layers (classes, enums, modules, and now decorators) that make TypeScript a complete language for real production applications, not just an annotation layer.
If you’re starting fresh, revisit How TypeScript Actually Works for the mental model everything else in this series builds on. If you came from a specific framework needing decorators, the Classes guide and Modules and Declaration Files guide round out the remaining pieces most framework code assumes you already know.