Decorators in TypeScript

TypeScript decorators are a way to modify or extend the behavior of classes, methods, or properties in a declarative manner. They are denoted by the @ symbol and are applied using a syntax similar to annotations in other languages. Decorators are commonly used in TypeScript with frameworks like Angular, NestJS, and libraries like MobX. They provide a clean and modular way to enhance or alter the functionality of various elements within a TypeScript application. Decorators are a powerful tool for metaprogramming and can be employed to address concerns like logging, validation, and dependency injection.

Enabling decorators in TypeScript

Ensure "experimentalDecorators": true is set in tsconfig.json:

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Simple decorator example

function logClassName(target: any) {
    // target is the constructor function of the class
    console.log(`Class name: ${target.name}`);
}

@logClassName
class MyClass {
    // ...
}


const myClassInstance = new MyClass();

With this simple example you see how to gain access to the class within the decorator function. Usually we want more, for example:

  • we want to pass values to our decorator. We do this by creating a decorator factory (more below)
  • we want to access values from other decorators. We do this by using reflection metadata (more below).

The role of Metadata Reflection API in decorators

Why and when to use metadata reflection in decorators?

Metadata reflection in decorators becomes valuable when you need to inspect or modify the structure of classes, methods, or properties at runtime. As mentioned earlier, reflection metadata can be used to access values from other decorators. For example: Let’s say you defined a decorator @Roles("admin", "user") on a method showMeResource. You need a way to access the roles specified in the decorator from within showMeResource by using metadata refelction:

// Define the Roles decorator
function Roles(...roles: string[]) {
  return function(target: any, key: string) {
    Reflect.defineMetadata('roles', roles, target, key);
  };
}

// Apply the Roles decorator to the showMeResource method
class MyClass {
  @Roles("admin", "user")
  showMeResource() {
    // Access roles using metadata reflection
    const roles = Reflect.getMetadata('roles', this, 'showMeResource');
    
    // Now 'roles' contains ["admin", "user"]
    console.log('Roles:', roles);
  }
}

// Instantiate the class and call the method
const instance = new MyClass();
instance.showMeResource();

The relation between showMeResource and the decorator lies in the use of Reflect.defineMetadata and Reflect.getMetadata using the same key, 'roles' in this case.

Metadata Reflection API is experimental

Reflection is used to temporarily store values. Metadata Reflection API is experimental and not yet part of the ECMAScript standard. This means that its behavior may change in future versions of TypeScript and it may not be supported by all JavaScript environments.

Installing Metadata Reflection

To use Reflect.metadata and other functions from the Metadata Reflection API in TypeScript, you need to install the reflect-metadata package and import it at the top of your TypeScript file.

import "reflect-metadata";

@Reflect.metadata("classKey", "classValue")
class MyClass {
    @Reflect.metadata("methodKey", "methodValue")
    myMethod() {
        // ...
    }
}

const classMetadataValue = Reflect.getMetadata("classKey", MyClass);
console.log(classMetadataValue); // "classValue"

const methodMetadataValue = Reflect.getMetadata("methodKey", MyClass.prototype.myMethod);
console.log(methodMetadataValue); // "methodValue"

Class decorator

In the following example the class decorator adds a property to a class.

function myClassDecorator<T extends { new(...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
        propB = "B";
    };
}

@myClassDecorator
class MyClass {
    public propA = "A";
}

const ex = new MyClass();
console.log(ex.propA); // output is 'A'
// @ts-ignore
// TypeScript still gives us error: Property 'decoratorProp' does not exist on type 'Example',
// because the decorator does not change the type 'Example'. So we just ignore it to show, that
// we can call playground.propB successfully anyway.
console.log(ex.propB);

// output is 'B'

Method decorator (using decorator factory)

A decorator factory is a function that returns the decorator function. We use it to allow passing in data (‘num’ in this case). @add is called first, then @subtract and finally myMethod.

// A decorator factory is a function that returns the decorator function.
// We use it to allow passing in data ('num' in this case).
// Must return 'any' to prevent 'Unable to resolve signature of method decorator when called as an expression'
function add(num: number): any {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        // target is the prototype of the class
        // propertyKey is the name of the method
        const originalMethod = descriptor.value;
        descriptor.value = function (...args: any[]) {
            console.log(`add ${num}`);
            return originalMethod.apply(this, args) + num;
        }
    };
}

function subtract(num: number): any {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function (...args: any[]) {
            console.log(`subtract ${num}`);
            return originalMethod.apply(this, args) - num;
        }
    };
}

class MyClass {
    @add(6)
    @subtract(3)
    myMethod(num: number) {
        console.log("myMethod " + num * 2);
        return num * 2;
    }
}

const myInstance = new MyClass();
const result = myInstance.myMethod(5);
console.log(result);

export {}
add 6
subtract 3
myMethod 10
13

Parameter decorator

In the following example we a method decorator @validate and a parameter decorator @required which checks if a property has a value. We use Reflect.defineMetadata to store the index of the parameter that we defined as required in an array. For example, when writing print(@required verbose?: boolean) we mark the parameter at index 0 as required. In the @validate method we use Reflect.getOwnMetadata to read the value and do the actual check.

So this example shows how two decorators work together and how they communicate by using functionality from import "reflect-metadata".

import "reflect-metadata";

const requiredMetadataKey = Symbol("required");

class BugReport {
    type = "report";
    title: string;

    constructor(t: string) {
        this.title = t;
    }

    @validate
    print(@required verbose?: boolean, second?: string, third?: string) {
        if (verbose) {
            return `type: ${this.type}\ntitle: ${this.title}`;
        } else {
            return this.title;
        }
    }
}


function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
    let existingRequiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
    existingRequiredParameters.push(parameterIndex);
    Reflect.defineMetadata(requiredMetadataKey, existingRequiredParameters, target, propertyKey);
}

function validate(target: Object, propertyName: string, descriptor: TypedPropertyDescriptor<(verbose: boolean) => string>) {
    let method = descriptor.value!;

    descriptor.value = function () {
        let requiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyName);
        if (requiredParameters) {
            for (let parameterIndex of requiredParameters) {
                if (parameterIndex >= arguments.length || arguments[parameterIndex] === undefined) {
                    throw new Error("Missing required argument.");
                }
            }
        }
        return method.apply(this, arguments as any);
    };
}

const report = new BugReport("the title");
console.log(report.print(true, 'B', 'C'));
// type: report
// title: the title

console.log(report.print(false));
// the title

console.log(report.print());
// Error: Missing required argument

Property decorator

In the following example we define @format("Hello, %s") on class property name. The decorator format uses Reflect.metadata which is a default metadata decorator factory. We use it to define metadata, namely the format string "Hello, %s" stored under key Symbol("format"). We can then use Reflect.getMetadata(formatMetadataKey, target, key) to retrieve the metadata value.

import "reflect-metadata";

const formatMetadataKey = Symbol("format");

function format(formatString: string) {
    return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, key: string | symbol) {
    return Reflect.getMetadata(formatMetadataKey, target, key);
}

class MyClass {
    @format("Hello, %s")
    name: string = "";

    greet() {
        let formatString = getFormat(this, "name");
        return formatString.replace("%s", this.name);
    }
}

const ex = new MyClass()
ex.name = "Mathias";
console.log(ex.greet());

export {};

Accessor decorator

In the following example we use @configurable to define whether an accessor should be configurable or not.

class Point {
    private _x: number;
    private _y: number;

    constructor(x: number, y: number) {
        this._x = x;
        this._y = y;
    }

    @configurable(false)
    get x() {
        return this._x;
    }

    @configurable(false)
    get y() {
        return this._y;
    }
}

function configurable(value: boolean) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.configurable = value;
    };
}

const point = new Point(1, 2);
console.log(point.y);

export {}

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.