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 {}