TypeScript Class Decorators

Class decorators in TypeScript are a powerful feature that allows you to observe, modify, or replace a class definition. Think of them as a "wrapper" for your class. When you apply a decorator to a class, you are essentially passing the class's constructor through a function that can tweak its behavior before any instances are even created.

While decorators are technically an "experimental" feature in TypeScript, they are the backbone of modern frameworks like Angular, NestJS, and TypeORM. They allow developers to use a declarative programming style, making code cleaner and more expressive by separating cross-cutting concerns (like logging or validation) from the core logic of the class.

Developer Tip: Decorators are executed when the class is defined, not when it is instantiated. This means the logic inside your decorator runs only once during the application's lifecycle.

 

What is a Class Decorator?

A class decorator is simply a function. This function receives one specific argument: the constructor of the class being decorated. Once you have access to this constructor, you can modify its prototype to add new methods, record metadata for a framework to read later, or even return a completely different constructor to override the original class logic.

In the TypeScript ecosystem, class decorators are identified by the @ symbol. They are placed immediately above the class declaration they are intended to modify.

Watch Out: There is a difference between "Experimental" decorators (the ones covered here) and the new ECMAScript Stage 3 decorators. Most current frameworks still rely on the experimental version, so ensure your environment is configured correctly.

 

Enabling Class Decorators in TypeScript

Because decorators are still considered experimental by the TypeScript team, they are disabled by default. If you try to use them without configuration, the compiler will throw an error.

Example: Enabling Class Decorators

To enable them, you must update your tsconfig.json file by setting experimentalDecorators to true within the compilerOptions block.

{
  "compilerOptions": {
    "target": "ES6",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  • The experimentalDecorators flag tells the compiler to support the @ syntax.
  • The emitDecoratorMetadata flag is often used alongside decorators to allow frameworks (like NestJS) to "read" the types of your constructor arguments at runtime.
Common Mistake: Forgetting to restart your development server or IDE build task after changing tsconfig.json. Changes to this file are usually not picked up automatically by hot-reloaders.

Syntax of Class Decorators

At its core, a class decorator function looks like this:

function MyClassDecorator(constructor: Function) {
  // Do something with the constructor
  console.log("Class definition found:", constructor.name);
}

In this snippet, constructor represents the class itself. You can inspect its name, look at its prototype, or add static properties to it.

 

Applying Class Decorators

To use your decorator, place it directly above your class. You don't need to call it like a function (unless you're using a factory, which we'll cover later); the @ symbol handles the execution for you.

Example: Applying a Class Decorator

function Reportable(target: Function) {
  target.prototype.reportedAt = new Date();
}

@Reportable
class User {
  constructor(public name: string) {}
}

const user = new User('Alice');
// We have to cast to 'any' here because TypeScript doesn't know about 
// properties added via prototype modification at compile time.
console.log((user as any).reportedAt); 
  • The Reportable decorator attaches a reportedAt property to the class prototype.
  • Every instance of User will now have access to this timestamp, even though it wasn't defined in the original class body.
Best Practice: Use class decorators for "meta" logic (like marking a class as a 'Controller' or 'Entity') rather than injecting business logic properties that might confuse other developers who can't see them in the class definition.

 

Modifying the Class Behavior

While adding properties to a prototype is useful, decorators are most powerful when they modify how a class behaves. You can use them to wrap existing methods or add utility functions that your application requires globally.

Example: Adding a Helper Method via Decorator

function Timestamped(target: Function) {
  target.prototype.getCreationDate = function() {
    return new Date();
  };
}

@Timestamped
class Document {
  constructor(public title: string) {}
}

const doc = new Document('Monthly Report');
console.log((doc as any).getCreationDate());

This approach is common in older libraries to "mix in" functionality. However, modern TypeScript developers often prefer Constructor Inheritance within decorators to ensure better type safety.

 

Replacing the Class Constructor

A class decorator can actually replace the original constructor with a new one. This is the most advanced use case. To do this, your decorator must return a new constructor function (or a new class) that extends the original.

Example: Replacing the Constructor

function Frozen(constructor: Function) {
  Object.freeze(constructor);
  Object.freeze(constructor.prototype);
}

@Frozen
class Config {
  static API_URL = "https://api.example.com";
}

// Config.API_URL = "http://hacked.com"; // This would fail at runtime because the class is frozen.

If you want to modify the instantiation logic, you can return a class that extends the original:

function WithDefaultRole<T extends { new (...args: any[]): {} }>(Base: T) {
  return class extends Base {
    role = "Guest";
    createdAt = new Date();
  };
}

@WithDefaultRole
class Profile {
  constructor(public username: string) {}
}

const myProfile = new Profile("jdoe");
console.log((myProfile as any).role); // Output: Guest
Developer Tip: When replacing a constructor, always extend the original class (extends Base) to ensure that the instanceof checks and existing properties still work as expected.

 

Class Decorator with Parameters

Sometimes you need to pass configuration data to your decorator. To do this, you use a Decorator Factory. This is a function that returns the actual decorator function.

Example: Class Decorator Factory

function Component(options: { selector: string, template: string }) {
  return function(constructor: Function) {
    constructor.prototype.selector = options.selector;
    constructor.prototype.template = options.template;
    console.log(`Registered component: ${options.selector}`);
  };
}

@Component({
  selector: 'app-root',
  template: '<h1>Hello World</h1>'
})
class AppComponent {}
  • The Component function is the factory. It takes an options object.
  • It returns an anonymous function which acts as the decorator, applying those options to the class.

 

Using Class Decorators for Dependency Injection (DI)

In high-level architecture, decorators act as "markers." A central container looks for these markers to decide how to manage the class. This is exactly how frameworks like NestJS or TypeDI work.

Example: Simulated Dependency Injection Registry

const registry = new Map<string, any>();

function Service(constructor: Function) {
  // Map the class name to its constructor for later instantiation
  registry.set(constructor.name, new (constructor as any)());
}

@Service
class DatabaseService {
  query(sql: string) {
    console.log(`Executing: ${sql}`);
  }
}

// Later in the app, we can fetch the instance without manually calling 'new'
const db = registry.get('DatabaseService');
db.query("SELECT * FROM users");
Best Practice: Use decorators to register classes into a registry or container rather than manually modifying global objects. This keeps your code modular and easier to test.

 

Summary

  • Class decorators act as wrappers that allow you to modify or annotate classes at the point of definition.
  • They receive the constructor function as their primary argument.
  • You must enable experimentalDecorators in your tsconfig.json to use them.
  • Decorator Factories allow you to pass custom configuration and parameters into your decorators.
  • They are ideal for cross-cutting concerns like logging, dependency injection, and metadata registration, keeping your business logic clean.