Using Decorators in Typescript

Written by andemosa | Published 2022/04/18
Tech Story Tags: typescript | decorators | javascript | factory-design-pattern | class-decorators | experimental-decorators | javascript-development | learn-javascript

TLDRTypescript allows Javascript developers to add static type-checking and other features to plain Javascript. The concept of Decorators is one of those extra features. Decorators are used to add special behaviors to a class or to members of a class. Decorators can be attached to a class declaration, method, property, accessor, or parameter.via the TL;DR App

Typescript is a superset of Javascript. Typescript allows Javascript developers to add static type-checking and other modern features to plain Javascript. Some of these extra features do get added to the standard Javascript(ECMAScript).

The concept of Decorators is one of those extra features. In this article, we would see what Decorators are and how they can be used in Typescript.

What are Decorators?

Decorators are used to add special behaviors to a class or to members of a class. The decorator is a function that takes in arguments and adds some annotation or metadata to the arguments in a declarative way.

Decorators are prefixed with an @. The value after the @ must evaluate to a function that has information about the decorated declaration.

Decorators can be attached to a class declaration, method, property, accessor, or parameter.

Decorators are currently a stage 2 proposal in Javascript, but they are available as an experimental feature in Typescript. To use decorators, we have to set the experimentalDecorators compiler option in the tsconfig.json file.

Decorator Factories

Since decorators are functions, we might need to pass in some additional options so as to customize how the decorator works. To do this, we make use of Decorator Factories. A Decorator Factory is simply a function that returns a function. This returned function would then implement the decorator.

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

Class Decorators

A Class Decorator is used to decorate a class declaration. The class decorator receives the constructor of the class as its only argument. The decorator can be used to observe, modify, or replace a class definition.

const classDecorator = (constructor: Function) => {
  // do something with your class
}

@classDecorator
class Person {}

The example below adds a property to the class by returning a new class that extends the constructor passed into the decorator and adds the property.

const addAgeToPerson = <T extends { new (...args: any[]): {} }>(
  originalConstructor: T
) => {
  return class extends originalConstructor {
    age: number;
    constructor(...args: any[]) {
      super();
      this.age = 28;
    }
  };
};

@addAgeToPerson
class Person {}

const person = new Person();
console.log(person.age); // 28

Property Decorators

A Property Decorator is used to decorate a property of a class. The property decorator receives two arguments.

  • The prototype of the class for an instance member OR the constructor function of the class for a static member.
  • The name of the property.

const propertyDecorator = (target: any, propertyName: string) => {
  // do something with your property
}

class Person {
  @propertyDecorator
  age = 28
}

Property decorators can be used to override the property being decorated. This can be done with the static method Object.defineProperty and a new setter and getter for the property.

In the example below, we are going to prevent the age from being changed to a value lower than 18.

const propertyDecorator = (target: any, propertyName: string) => {
  let currentAge: number = target[propertyName];

  Object.defineProperty(target, propertyName, {
    set: (newAge: number) => {
      if (newAge < 18) {
        return;
      }
      currentAge = newAge;
    },
    get: () => currentAge
  });
}

class Person {
  @propertyDecorator
  age = 28
}

const person = new Person();
console.log(person.age); // 28

person.age = 16;
console.log(person.age); // 28

person.age = 24;
console.log(person.age); // 24

Accessor Decorators

An Accessor Decorator is used to decorate an accessor property of a class. The accessor decorator gets applied to the Property Descriptor for that accessor.

Since the Property Descriptor combines both the get and set accessor, all decorators for the member must be applied to one accessor, not to both of the accessors.

The accessor decorator receives three arguments.

  • The prototype of the class for an instance member OR the constructor function of the class for a static member.
  • The name of the accessor.
  • The Property Descriptor for the accessor.

const accessorDecorator = (target: any, memberName: string, descriptor: PropertyDescriptor) => {
  // do something with your accessor
  console.log('Accessor decorator!');
  console.log(target);
  console.log(memberName);
  console.log(descriptor);
}

class Product {
  title: string;
  private _price: number;

  @accessorDecorator
  get price() {
    return this._price
  }

  set price(val: number) {
    if (val > 0) {
      this._price = val;
    } else {
      throw new Error('Price cannot be lower than zero!');
    }
  }


  constructor(t: string, p: number) {
    this.title = t;
    this._price = p;
  }
}

When the above code runs, it produces the output below. As seen from the result, the decorator receives both the set and get accessor in the Property Descriptor, despite being added to just one of the accessors.

The value returned from the accessor decorator would be used as the new Property Descriptor for the member.

In the example below, we are using a decorator factory to change the configurable value of the accessor.

const configurable = (value: boolean) => {
  return (target: any, memberName: string, descriptor: PropertyDescriptor) => {
    descriptor.configurable = value;
  }
}


class Product {
  title: string;
  private _price: number;

  @configurable(false)
  get price() {
    return this._price
  }

  set price(val: number) {
    if (val > 0) {
      this._price = val;
    } else {
      throw new Error('Price cannot be lower than zero!');
    }
  }


  constructor(t: string, p: number) {
    this.title = t;
    this._price = p;
  }
}

Method Decorators

A Method Decorator is used to decorate a class method. The method decorator is applied to the Property Descriptor for that method.

The method decorator receives three arguments.

  • The prototype of the class for an instance member OR the constructor function of the class for a static member.
  • The name of the method.
  • The Property Descriptor for the method.

const methodDecorator = (target: any, methodName: string, descriptor: PropertyDescriptor) => {
  // do something with your method
  console.log('Method decorator!');
  console.log(target);
  console.log(methodName);
  console.log(descriptor);
}

class Product {
  title: string;
  private _price: number;

  constructor(t: string, p: number) {
    this.title = t;
    this._price = p;
  }

  @methodDecorator
  getPriceWithDiscount(discount: number) {
    return this._price - (this._price * discount) / 100;
  }
}

When the above code runs, it produces the output below.

The value returned from the accessor decorator would be used as the new Property Descriptor for the member.

In the example below we would use a method decorator to add a deprecated warning to our method.

We do this by returning a wrapper function that wraps around the original method and adds the new functionality. This wrapper function is then placed in a getter function and returned as the new Property Descriptor.

const methodDecorator = (target: any, methodName: string, descriptor: PropertyDescriptor) => {
  const originalMethod = descriptor.value;

  // new property descriptor
  const newDescriptor: PropertyDescriptor = {
    configurable: true,
    enumerable: false,
    // getter function
    get() {
      // wrapper function
      const newMethod = (...args: any[]) => {
        console.warn(`Method ${methodName} is deprecated`);
        return originalMethod.apply(this, args)
      }
      return newMethod;
    }
  };

  return newDescriptor;
}

class Product {
  title: string;
  private _price: number;

  constructor(t: string, p: number) {
    this.title = t;
    this._price = p;
  }

  @methodDecorator
  getPriceWithDiscount(discount: number) {
    return this._price - (this._price * discount) / 100;
  }
}

const prod = new Product("shoes", 100)

console.log(prod.getPriceWithDiscount(20))

The code above produces the result below.

Parameter Decorators

A Parameter Decorator is is used to decorate a parameter of a class method or a class constructor.

The parameter decorator receives three arguments.

  • The prototype of the class for an instance member OR the constructor function of the class for a static member.
  • The name of the method that uses the parameter.
  • The index of the parameter in the function’s parameter list.

const parameterDecorator = (target: any, methodName: string, position: number) => {
  // do something with your parameter
  console.log('Parameter decorator!');
  console.log(target);
  console.log(methodName);
  console.log(position);
}

class Product {
  title: string;
  private _price: number;

  constructor(t: string, p: number) {
    this.title = t;
    this._price = p;
  }

  getPriceWithDiscount(@parameterDecorator discount: number) {
    return this._price - (this._price * discount) / 100;
  }
}

When the above code runs, it produces the output below.

Conclusion

  • Decorators are functions that add special behaviors to a class or members of a class
  • To customize how a decorator works, use a decorator factory which allows you to pass in additional options
  • Examples of libraries that make use of Decorators include Angular, Typegoose, and Nestjs.


Written by andemosa | Survived an Infinite Tsukuyomi. Loves football
Published by HackerNoon on 2022/04/18