Skip to main content

What are decorators in TypeScript?

TypeScript decorators are functions that wrap classes, methods, properties, or parameters using @decorator syntax, and run at the point where the class or member is defined, not when it gets called.

Theory

TL;DR

  • Think of a decorator like a stamp on an envelope: the letter inside stays the same, but the stamp changes how it gets handled.
  • The compiler transforms @Logger class User {} into Logger(User) before any code runs.
  • Requires "experimentalDecorators": true in tsconfig.json.
  • Use for cross-cutting concerns: logging, validation, routing metadata.
  • For simple one-off logic, a plain function is less magic and easier to test.

Quick example

typescript
// tsconfig.json: "experimentalDecorators": true function LogCreation(target: any) { console.log(`Creating: ${target.name}`); } @LogCreation class User { name = "Alice"; } // Output: "Creating: User" // Fires when the file loads, not when new User() is called

When I first ran into this in a NestJS codebase, the "runs at module load, not at instantiation" behavior caught me completely off guard. It still trips up most developers the first time.

How the compiler handles decorators

TypeScript scans for @decorator during the emit phase and rewrites it to a direct function call. @Logger class User {} becomes roughly User = Logger(User). No special runtime. Just a transformed AST.

With "emitDecoratorMetadata": true enabled, the compiler also attaches Reflect.metadata symbols via tslib. This is how Angular and NestJS inject dependency types at runtime without you passing them explicitly.

TypeScript 5.0+ has two modes: legacy (experimentalDecorators) and the newer TC39 stage 3 proposal mode. Most existing frameworks, including Angular and NestJS, still use legacy mode.

When to use

  • Repeated boilerplate across many classes: logging, performance timing, error handling. A decorator keeps it DRY.
  • Framework contracts: Angular's @Component, NestJS's @Get. These are not optional.
  • DTO validation with class-validator: @IsEmail(), @IsString().
  • Skip decorators for logic that only appears once. A plain wrapper function is simpler to read and test.
  • Avoid on performance-critical hot paths. Every decorator adds a function call layer.

Common mistakes

Forgetting experimentalDecorators in tsconfig:

typescript
@Log class User {} // Error: Decorators not enabled

Add "experimentalDecorators": true to tsconfig.json, or pass --experimentalDecorators to the CLI.

Not returning the descriptor in method decorators:

typescript
function BadLog(target: any, key: string, desc: PropertyDescriptor) { desc.value = () => console.log("called"); // Modifies but never returns }

Bundlers like esbuild expect the decorator to return the modified PropertyDescriptor. Always return desc.

Applying a decorator to an arrow function class field:

typescript
class SearchService { @Throttle(1000) search = () => { /* ... */ }; // Throttle checks desc.value - it's undefined here }

Arrow function fields don't have a value on their descriptor the same way regular methods do. The decorator runs but silently does nothing. Use regular methods if you need method decorators.

Relying on Reflect.getMetadata without both flags:

typescript
const type = Reflect.getMetadata("design:type", target, key); // Returns undefined

You need "emitDecoratorMetadata": true in tsconfig AND import "reflect-metadata" in your entry file. Missing either one gives you undefined everywhere.

Decorating private fields with # syntax:

typescript
class C { @Log #private = 1; // No runtime descriptor for private fields }

Private fields using # don't have accessible PropertyDescriptor objects at runtime. Use TypeScript's private keyword if the property needs to be decorated.

Real-world usage

  • NestJS: @Controller('/users'), @Get(':id'), @Body() for routing and parameter extraction.
  • Angular: @Component, @Injectable, @Input for DI and component metadata.
  • TypeORM: @Entity(), @Column(), @PrimaryGeneratedColumn() for schema definition.
  • class-validator: @IsEmail(), @MinLength(8) for DTO validation in request pipelines.
  • class-transformer: @Expose(), @Transform() for serialization control.

Follow-up questions

Q: What arguments does a method decorator receive?
A: Three: target (the class prototype for instance methods, the constructor for static methods), propertyKey (the method name as string or symbol), and descriptor (the PropertyDescriptor with value, writable, enumerable, configurable).

Q: In what order do stacked decorators execute?
A: Decorator factories run top-to-bottom. The actual decorator functions apply bottom-to-top. So @A @B method() calls A() then B() as factories, but applies B first, then A wraps the result.

Q: Can you access constructor parameters from a class decorator?
A: No. A class decorator receives only the constructor function. To intercept constructor parameters, return a new class that extends the original and overrides the constructor.

Q: How do decorators behave with inheritance?
A: Method decorators on a child class modify the child's prototype, not the parent's. If a parent has a decorated method and the child overrides it without a decorator, the child's version is undecorated.

Q: Why use decorator factories like @Throttle(1000) instead of plain @Throttle?
A: A factory is a function that returns the actual decorator. This pattern lets you pass configuration per use site. Without it you cannot parameterize behavior like debounce delay or log level.

Examples

Basic: class decorator for logging

typescript
function LogCreation(constructor: Function) { console.log(`Class defined: ${constructor.name}`); } @LogCreation class UserService { constructor(private name: string) {} greet() { return `Hello, ${this.name}`; } } // Output: "Class defined: UserService" - fires when the module loads const svc = new UserService("Alice"); // Nothing extra logged here console.log(svc.greet()); // "Hello, Alice"

The decorator runs once when the module is parsed, not on every new UserService(). This is the mental model correction most developers need on first contact.

Intermediate: method decorator factory with throttle

typescript
function Throttle(ms: number) { return function (target: any, key: string, desc: PropertyDescriptor) { if (!desc.value || typeof desc.value !== "function") return desc; let lastCall = 0; const original = desc.value; desc.value = function (...args: any[]) { const now = Date.now(); if (now - lastCall < ms) return; lastCall = now; return original.apply(this, args); }; return desc; }; } class SearchService { @Throttle(1000) search(query: string) { console.log(`Searching: ${query}`); // In production: fetch(`/api/search?q=${query}`) } } const service = new SearchService(); service.search("typescript"); // Logs: "Searching: typescript" service.search("decorators"); // Ignored - less than 1000ms passed

The factory pattern (Throttle(1000)) lets you configure the delay per method. The return desc is not optional: bundlers expect the descriptor back, and skipping it produces fragile behavior depending on the toolchain.

Advanced: route metadata collector (NestJS pattern)

typescript
import "reflect-metadata"; function Get(path: string) { return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { const routes = Reflect.getMetadata("routes", target.constructor) || []; routes.push({ path, method: "GET", handler: propertyKey }); Reflect.defineMetadata("routes", routes, target.constructor); return descriptor; }; } class UserController { @Get("/users") getUsers() { return [{ id: 1, name: "Alice" }]; } @Get("/users/:id") getUserById() { return { id: 1, name: "Alice" }; } } // Reading metadata to wire up routes: const routes = Reflect.getMetadata("routes", UserController); console.log(routes); // [ // { path: '/users', method: 'GET', handler: 'getUsers' }, // { path: '/users/:id', method: 'GET', handler: 'getUserById' } // ]

This is the actual pattern NestJS uses internally. Decorators store metadata on the class. A bootstrapper reads it later to register Express routes. The decorator itself never touches Express at all. That separation is what makes the pattern composable.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?