TypeScript Decorators: A Comprehensive Guide

Unlocking the Potential and Flexibility of TypeScript Decorators with Simple, Illustrative Examples for a Solid Foundation

Irene Smolchenko
Level Up Coding

--

A wall covered in hand-drawn patterned paintings with a table nearby holding paintbrushes in cups. It hints at a creative and decorative setting.
Photo by laura adai on Unsplash

Decorators are a powerful feature that can enhance and modify your code during compilation. Whether you’re new to TypeScript or an experienced developer, this article will show you how to harness the true power of decorators that can take your TypeScript code to the next level.

Let’s dive right in!

Introduction

Please note that decorators are a stage 2 proposal for JavaScript and are currently an experimental feature in TypeScript.

In order to use decorators, you need to enable the ‘experimentalDecorators’ compiler option, either:

  • trough the command line: tsc --taget ES5 --experimentalDecorators
  • or by adding it to your tsconfig.json file:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}

Decorators

A decorators in TypeScript are a special kind of feature that allows you to enhance or modify your code in a flexible way. They provide a way to attach additional functionality to classes, methods, properties, or parameters.

Decorators use the form @expression, where expression must evaluate to a function that will be called at runtime with information about the decorated declaration.
— TypeScript docs

Here’s a simple example to illustrate the above:

// define a decorator function called 'myDecorator'
function myDecorator(target: any) {
console.log("Decorating:", target.name);
}

@myDecorator
class MyClass {
// Class implementation...
}


// output: "Decorating: MyClass"

The @myDecorator syntax is used to apply the decorator to the MyClass declaration. When the code is executed, the myDecorator function will be called with the MyClass constructor function as the target parameter.

Decorator Factories

If we want to customize how a decorator is applied to a declaration, we can write a decorator factory. A Decorator Factory is simply a function that returns the expression that will be called by the decorator at runtime.
— TypeScript Docs

In some cases, we may need to customize the behavior of a decorator by passing additional options. This is where decorator factories come into play.

A Decorator Factory is a function that returns another function, which acts as the actual decorator. It allows us to configure and customize the behavior of the decorator before it is applied to a target.

const decoratorFactory = (value: string) => {
// The decorator factory returns a decorator function
return (target: any) => {
// The returned decorator function uses 'target' and 'value' to apply the decorator logic
// ...
};
};

TypeScript Decorators in Practice

Class Decorators

Class decorators are a type of decorator that can be applied to classes.
They -

  • allow you to modify or extend the behavior of a class or its constructor, and can even replace the entire class with a new one.
  • receive the class constructor as their target and can be used to add properties, methods, or modify metadata.
  • are executed at runtime when the class is defined. By having access to the class constructor, they enable behavior manipulation before any instances are created.

Have a look at the example below:

function uppercaseConstructor<T extends new (...args: any[]) => any>(target: T): T {
const originalConstructor = target;

function modifiedConstructor(...args: any[]) {
const instance = new originalConstructor(...args);

Object.keys(instance).forEach((key) => {
if (typeof instance[key] === 'string') {
instance[key] = instance[key].toUpperCase();
}
});

return instance;
}

modifiedConstructor.prototype = originalConstructor.prototype;

return modifiedConstructor as unknown as T; // This type assertion bypasses TypeScript's allows us to assume that the returned value will match type T, even if there is no direct type relationship.
}

@uppercaseConstructor
class ExampleClass {
constructor(private name: string, private city: string) {}

displayInfo() {
console.log(`Name: ${this.name}, City: ${this.city}`);
}
}

const example = new ExampleClass("John Doe", "New York");
example.displayInfo();

The class decorator uppercaseConstructor converts all string properties of the class instance to uppercase.

Inside the decorator, we define a modifiedConstructor function that replaces the original constructor. This modified constructor creates an instance of the original constructor and then iterates over its properties. If a property value is a string, it converts it to uppercase.

We also ensure that the prototype of the modified constructor is set to be the same as the original constructor’s prototype.

When the ExampleClass is defined with the @uppercaseConstructor syntax, the decorator is invoked. It modifies the constructor to transform string properties to uppercase when an instance is created.

Finally, the output is:

// Name: JOHN DOE, City: NEW YORK

Method Decorators

Method decorators are a type of decorator that can be applied to class methods (both instance methods and static methods), allowing you to modify or extend the behavior of a specific method. They -

  • have the ability to access and modify method arguments, return values, or even replace the method entirely.
  • are functions that receive three parameters: the target object (class prototype), the method name, and the property descriptor (an object that describes the attributes and behavior of a property).
  • are executed when the class is defined, allowing for modification of method behavior before any instances are created.

Let’s see an example (inspired by the TypeScript’s 5.0 release notes):

// no decorators here
class Person {
name: string;
constructor(name: string) {
this.name = name;
}

greet() {
console.log("LOG: Entering method."); // can be extracted
console.log(`Hello, my name is ${this.name}.`);
console.log("LOG: Exiting method."); // can be extracted
}
}

Since the logic in greet can be needed in other functions, there are parts in it that we can extract. We can write a function called loggedMethod that looks like the following:

function loggedMethod(_target: any, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor {
const originalMethod = descriptor.value;

function replacementMethod(this: any, ...args: any[]) {
console.log("LOG: Entering method."); // extracted
const result = originalMethod.call(this, ...args);
console.log("LOG: Exiting method."); // extracted
return result;
}

descriptor.value = replacementMethod;
return descriptor;
}

The line const result = originalMethod.call(this, …args); is responsible for invoking the original method and capturing its result. By using it, the decorator is able to execute the original method within the context specified by this and with the provided arguments …args.

Now we can use loggedMethod to decorate the method greet. It will replace the original definition of greet:

class Person {
name: string;
constructor(name: string) {
this.name = name;
}

@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}

const p = new Person("Ron");
p.greet();

// Output:
// LOG: Entering method.
// Hello, my name is Ron.
// LOG: Exiting method.

Decorator Composition

Decorator composition is the concept of applying multiple decorators to a target in a specific order to achieve a desired behavior or functionality. It allows you to combine and chain decorators to modify or extend a behavior.

Multiple decorators can be applied to a declaration in TypeScript either on a single line or on multiple lines. The order of the decorators matters because each decorator builds upon the modifications made by the previous decorator.

The example and output below clearly demonstrate how to use it effectively:

function first() {
console.log("first(): factory evaluated");
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("first(): called");
};
}

function second() {
console.log("second(): factory evaluated");
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
console.log("second(): called");
};
}

class ExampleClass {
@first()
@second()
method() {}
}

“1. The expressions for each decorator are evaluated top-to-bottom.”
“2. The results are then called as functions from bottom-to-top.”

// first(): factory evaluated
// second(): factory evaluated
// second(): called
// first(): called

Property Decorators

Property decorators are a type of decorator that can be applied to class properties (both instance and static). They —

  • provide a way to customize or enhance the behavior of specific properties within a class before any instances are instantiated. For example, by using the static method Object.defineProperty you can customize various aspects of a property, such as its value, writability, enumerability, and configurability.
  • are functions that receive two parameters: the target object (the prototype of the class for an instance member OR the constructor function of the class for a static member) and the property name.
  • are executed when the class is defined, not when instances of the class are created.

Here’s an example:

function readonly(target: any, propertyName: string) {
Object.defineProperty(target, propertyName, {
writable: false,
});
}

class ExampleClass {
@readonly
name: string = "John";
}

const example = new ExampleClass();
example.name = "Alice"; // Error: Cannot assign to 'name' because it is a read-only property

Inside the decorator, we use Object.defineProperty to redefine the property with the writable attribute set to false. This makes the property read-only.

When the @readonly syntax is used to apply the decorator to the name property, the decorator is invoked. It modifies the property’s descriptor to make it read-only.

Finally, when we create an instance of ExampleClass and try to assign a new value to the name property, it will result in an error.

Parameter decorators

Parameter decorators in TypeScript allow you to modify the behavior or metadata of function parameters. They are decorators specifically applied to parameters within a function declaration, right before the parameter declaration, and can be used to intercept, modify, or track the values passed to a function:

function ParameterLogger(
target: any,
methodName: string,
parameterIndex: number) {
console.log(`Parameter ${parameterIndex + 1} of ${methodName} has been accessed.`);

}

class ExampleClass {
exampleMethod(@ParameterLogger param1: string, @ParameterLogger param2: number) {
console.log("Inside exampleMethod");
}
}

const exampleInstance = new ExampleClass();
exampleInstance.exampleMethod("Hello", 42);

Here, we defined a parameter decorator called ParameterLogger which takes three parameters.

  • The target parameter represents the object that the method is a property of (the constructor function of the class ExampleClass).
    The ‘target’ parameter typically represents the target of the decorator, which can be the constructor function for a class, the prototype of a class for an instance member, or the constructor function of a class for a static member.
  • The methodName is the name of the method where the parameter is declared (exampleMethod).
  • The parameterIndex is the index of the parameter within the method’s parameter list.

The output is:

// Parameter 1 of exampleMethod has been accessed.
// Parameter 2 of exampleMethod has been accessed.
// Inside exampleMethod

Pretty straightforward! 😃

Conclusion

In this article we started with defining decorators and decorator factories and progressed to showing how they are used in different roles, such as — Class Decorators, Parameter decorators, Property decorators and Method Decorators.

Typing decorators can be fairly complex, but they are also very handy when it comes to writing more modular, reusable, and flexible code. They enhance developer productivity and make it easier to maintain and evolve your TypeScript applications.

I hope you found this post useful. ✨
Stay tuned for future content! If you’re new here, feel free to explore my other articles, and follow me for updates and more valuable insights.

By buying me a virtual croissant on Buy Me a Coffee, you can directly support my creative journey. Your contribution helps me continue creating high-quality content. Thank you for your support!

--

--

🍴🛌🏻 👩🏻‍💻 🔁 Front End Web Developer | Troubleshooter | In-depth Tech Writer | 🦉📚 Duolingo Streak Master | ⚛️ React | 🗣️🤝 Interviews Prep