đź’™ TypeScript Decorators in Brief

Necati Ă–zmen - Oct 12 '23 - - Dev Community


refine repo

Author: Abdullah Numan

Introduction

TypeScript decorators are an extension that allows adding annotation and metaprogramming to class declarations and their members in TypeScript. TypeScript supports decorators syntax as an experimental feature, which is distinct from JavaScript decorators that is currently a Stage 3 ECMAScript proposal. This post provides a brief walkthrough into the use of TypeScript decorators with examples from decorating a User class, its properties, accessors and methods.

These decorators are an extension that implements the Decorator Pattern with native syntax. It is supported for class-based programming, which was introduced with ES6. TypeScript decorators allow us to sneak into run-time JavaScript objects to annotate and manipulate them. As such, TypeScript decorators are defined with built-in
syntax are commonly leveraged in TS libraries for logging events, warnings, and observing, modifying and replacing objects and their members.

In this post, we explore four main types of TypeScript Decorators with examples from a class that resembles those in typical class-based JavaScript / TypeScript libraries. We first introduce and understand the TypeScript decorators syntax. And then, using an existing User class and its members, we see how to decorate the class itself and where necessary - its properties, their accessors, and other class methods.

Applying the decorators is done with @, which exposes several parameters such as the class constructor or prototype and, where applicable, the member key, the member descriptor, and the parameter index of a method argument. These exposed parameters are utilized to define necessary decorator functions that observe, modify or replace the construct subject to decoration.

In the sections ahead, we work with an existing User class that we seek to decorate. However, below let's first talk about the environment we need to get started.

Prerequisites

TypeScript and Runtime

In order to properly follow this post and test out the examples, you need to have a JavaScript engine. It could be Node.js in your local machine with TypeScript installed or you could use the TypeScript Playground.

Enabling Decorators Support

TypeScript decorators are supported under the experimental flag. So, we have to enable it from the tsconfig.json file by adding the following entry to compilerOptions:

// Inside tsconfig.json

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}
Enter fullscreen mode Exit fullscreen mode

If you're running a file in a Node.js shell, activate decorators by running the following command:

tsc --experimentalDecorators
Enter fullscreen mode Exit fullscreen mode

In TypeScript Playground, you can activate decorators first by visiting the TS Config dropdown and then selecting experimentalDecorators from the Language and Environment section.

With the environment ready to support decorators, let's now look at the existing User class that we are decorating throughout this post.

Decorating a Class with TypeScript Decorators

The User class that we want to decorate initially looks like below:

class User {
  private static userType: string = "Generic";
  private _email: string;

  public username: string;
  public addressLine1: string = "";
  public addressLine2: string = "";
  public country: string = "";

  constructor(username: string, email: string) {
    this.username = username;
    this._email = email;
  }

  get userType() {
    return User.userType;
  }

  get email() {
    return this._email;
  }

  set email(newEmail: string) {
    this._email = newEmail;
  }

  address(): any {
    return `${this.addressLine1}\n${this.addressLine2}\n${this.country}`;
  }
}

const p = new User("exmapleUser", "example@exmaple.com");
p.addressLine1 = "1, New Avenue";
p.addressLine2 = "Bahcelievler, Istanbul";
Enter fullscreen mode Exit fullscreen mode

As we can see, we have a mix of private and public properties among static and instance members describing different attributes of a user. We have accessors and also an address() instance method that returns the address of the user.

TypeScript allows decorating the class constructor itself, its properties and their accessors, and method members. In the coming sections, one by one, we implement a @frozen decorator on the User class, @required on a couple of properties, @enumerable on a getter and @deprecated on an instance method.

TypeScript also allows us to decorate method and constructor parameters. However, we are not covering it in this quick exploration as its use cases become relevant when we need deeper insight into runtime behaviors of properties and method arguments by relying on libraries such as reflect-metadata.

The User class after applying the above mentioned decorators looks like this:

@frozen
class User {
  private static userType: string = "Generic";

  @required
  private _email: string;

  @required
  public username: string;

  public addressLine1: string = "";
  public addressLine2: string = "";
  public country: string = "";

  constructor(username: string, email: string) {
    this.username = username;
    this._email = email;
  }

  @enumerable(false)
  get userType() {
    return User.userType;
  }

  get email() {
    return this._email;
  }

  set email(newEmail: string) {
    this._email = newEmail;
  }

  @deprecated
  address(): any {
    return `${this.addressLine1}\n${this.addressLine2}\n${this.country}`;
  }
}

const p = new User("exampleUser", "example@example.com");
p.addressLine1 = "1, New Avenue";
p.addressLine2 = "Bahcelievler, Istanbul";
Enter fullscreen mode Exit fullscreen mode

TypeScript Decorators Syntax

As we can see above, the syntax for using a decorator follows this pattern:

@decoratorName
itemToBeDecorated
Enter fullscreen mode Exit fullscreen mode

Here, @ invokes the decoratorName function on the itemToBeDecorated subject. And it exposes appropriate parameters for the decoratorName to observe, modify and replace. These parameters vary according to whether the item is a class, property, method, or parameter. For example, when we want to decorate a class, the class constructor or the prototype is made available to the decorator function invoked by @. It then falls on the class decorator function to make use of this parameter for decorating the class.

Let's explicate the idea by focusing on the @frozen decorator call which is a class decorator.



Class Decoration in TypeScript

The @frozen decorator is applied to our User class. The decorator invocation with @ exposes the constructor function of the User class to the frozen function. This means we can pass it to frozen and use it for manipulating the class. We want our frozen function to freeze the User class, like this:

function frozen(target: Function) {
  Object.freeze(target);
  Object.freeze(target.prototype);
}
Enter fullscreen mode Exit fullscreen mode

When we apply the decorator to the class, the target is always the constructor function of the User class.

And now if we try to add a new static member to User, we get an error:

console.log(Object.isFrozen(User)); // true
User.addNewProp = "Trying to add new prop value"; // [ERR]: Cannot add property addNewProp, object is not extensible
console.log(Object.isFrozen(new User("example", "example@example.com"))); // false
Enter fullscreen mode Exit fullscreen mode

Notice that an instance of the User class is not frozen, rather the class itself is. This means a class decorator is applied to the prototype and not to the instance.

Next, we are going to consider decorating properties.

Property Decorators in TypeScript

If we look back at the User class above, we have applied a @required decorator to a couple of properties, namely: username and email. We want @required to throw an error if username and email is not initialized at user construction.

Our required decorator looks like this:

function required(target: any, key: string) {
  let currentValue = target[key];

  Object.defineProperty(target, key, {
    set: (newValue: string) => {
      if (!newValue) {
        throw new Error(`${key} is required.`);
      }
      currentValue = newValue;
    },
    get: () => currentValue,
  });
}
Enter fullscreen mode Exit fullscreen mode

Applying a decorator to a property exposes the target and key parameters to the decorator function. The target is the constructor function if we apply the decorator to a static member and the prototype of the class if it is applied on an instance property. The key is the member name.

Our required function above grabs them to redefine a decorated property with the same member name but a different setter, effectively replacing the existing definition of the member value.

Notice that it is possible to replace the descriptor value of the member with Object.defineProperty() method without necessarily accessing the member descriptor itself. This is useful in decorating properties.

And now if we try to instantiate a user without a value for username or email, we'll get an error thrown:

const p = new User("", "example@example.com"); // [ERR]: username is required.
const u = new User("example", ""); // [ERR]: _email is required.
Enter fullscreen mode Exit fullscreen mode

With this done, let's now see how property accessors should be decorated.

Accessor Decorators in TypeScript

Applying a decorator to a property accessor exposes the property descriptor in addition to the target (constructor/prototype) and the key (member name). With the member descriptor at our disposal, we can directly operate on the member metadata.

If we revisit the User class with decorators applied, we see that we have an @enumerable(false) decorator applied to the userType() getter method.

The enumerable wrapper below returns a function that takes the member descriptor and sets its enumerable attribute to isEnumerable:

function enumerable(isEnumerable: boolean) {
  return (target: any, key: string, descriptor: PropertyDescriptor) => {
    descriptor.enumerable = isEnumerable;
    console.log(
      "The enumerable property of this member is set to: " +
        descriptor.enumerable
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

This time, thanks to the access to the member descriptor, we don't really need to redefine the same property with Object.defineProperty().

With @enumerable(false) applied to a member, the console prints the following message at:

// The enumerable property of this member is set to: false
Enter fullscreen mode Exit fullscreen mode

TypeScript Decorator Factories

Take a close look at the enumerable decorator. It is taking a parameter that is actually passed at decorator invocation. Rather than purely being a decorator, enumerable is a decorator factory that produces the decorator for us by taking a Boolean input from us. Such decorator factories are commonly used to customize decorator behavior and make them reusable.

Method Decorators in TypeScript

In our User class, we have a @deprecated method decorator applied which is generally intended to inform the console that the method it is applied to is deprecated, alongside doing its usual stuff. Like accessor decorators, invoking a method decorator also exposes three parameters: the target, which can be the constructor for a static method or the class prototype for an instance method, the member key for the method and the member descriptor.

Our deprecated decorator function looks as below:

function deprecated(target: any, key: string, descriptor: PropertyDescriptor) {
  const originalDef = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log(`Warning: ${key}() is deprecated. Use other methods instead.`);
    return originalDef.apply(this, args);
  };
  return descriptor;
}
Enter fullscreen mode Exit fullscreen mode

Here the manipulation of the descriptor value is explicit, as we can reset it directly and return the new descriptor after implementing the decoration. Access to the descriptor makes it easier to change the method implementation on the instance at runtime.

With the @deprecated decorator applied to address(), the following warning is logged to the console:

// Warning: address() is deprecated. Use other methods instead.
Enter fullscreen mode Exit fullscreen mode

These are pretty much the major examples of decorators in TypeScript which can help us decorate a class and its members. Using parameter decorators give us more insight into how arguments act out in runtime. It is very useful to leverage the reflect-metadata library with parameter decorators. For a few exmaples, please check out this section of the TypeScript decorators documentation.

Summary

TypeScript decorators are very useful for annotations such as deprecation warnings and logging. They are mighty for metaprogramming in JavaScript applications. In this post, we have briefly explored four main types of decorators that can be implemented with TypeScript, namely: class decorators, property decorators, accessor decorators and method decorators. We also saw how decorator factories produce reusable decorators in TypeScript.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .