TypeScript decorator Complete Guide

Posted by Jeb. on Sat, 05 Mar 2022 16:32:24 +0100

Decorators make the world of TypeScript better. Many of the libraries we use are built on this powerful feature, such as Angular and Nestjs . In this blog, I will introduce the decorator and many details of it. I hope that after reading this article, you can understand when and how to use this strong feature.

overview

Decorator is essentially a special function, which is applied in:

  1. class
  2. Class properties
  3. Class method
  4. Class accessor
  5. Parameters of class methods

So the application decorator is actually like combining a series of functions, similar to high-order functions and classes. We can easily achieve this through decorators proxy pattern Make code simpler and implement other more interesting capabilities.

The syntax of the decorator is very simple. Just add the @ symbol in front of the decorator you want to use, and the decorator will be applied to the target:

function simpleDecorator() {
  console.log('---hi I am a decorator---')
}

@simpleDecorator
class A {}

There are five kinds of ornaments we can use:

  1. Class decorator
  2. Attribute decorator
  3. Method decorator
  4. Accessor decorator
  5. Parameter decorator

Let's take a quick look at these five ornaments:

// Class decorator
@classDecorator
class Bird {

  // Attribute decorator
  @propertyDecorator
  name: string;
  
  // Method decorator
  @methodDecorator
  fly(
    // Parameter decorator
    @parameterDecorator
      meters: number
  ) {}
  
  // Accessor decorator
  @accessorDecorator
  get egg() {}
}

implement

opportunity

The decorator is only applied once when interpreting execution, for example:

function f(C) {
  console.log('apply decorator')
  return C
}

@f
class A {}

// output: apply decorator

The code here will print the apply decorator in the terminal, even if we don't actually use class A.

Execution sequence

The execution sequence of different types of decorators is clearly defined:

  1. Instance members:

Parameter decorator - > method / accessor / attribute decorator 2 Static members:
Parameter decorator - > method / accessor / attribute decorator 3 Constructor: parameter decorator 4 Class decorator

For example, consider the following code:

function f(key: string): any {
  console.log("evaluate: ", key);
  return function () {
    console.log("call: ", key);
  };
}

@f("Class Decorator")
class C {
  @f("Static Property")
  static prop?: number;

  @f("Static Method")
  static method(@f("Static Method Parameter") foo) {}

  constructor(@f("Constructor Parameter") foo) {}

  @f("Instance Method")
  method(@f("Instance Method Parameter") foo) {}

  @f("Instance Property")
  prop?: number;
}

It will print the following information:

evaluate:  Instance Method
evaluate:  Instance Method Parameter
call:  Instance Method Parameter
call:  Instance Method
evaluate:  Instance Property
call:  Instance Property
evaluate:  Static Property
call:  Static Property
evaluate:  Static Method
evaluate:  Static Method Parameter
call:  Static Method Parameter
call:  Static Method
evaluate:  Class Decorator
evaluate:  Constructor Parameter
call:  Constructor Parameter
call:  Class Decorator

You may notice that the instance property prop is executed later than the instance method, but the static property static prop is executed earlier than the static method. This is because for property / method / accessor decorators, the order of execution depends on the order in which they are declared.

However, in the same method, the execution order of decorators with different parameters is opposite, and the decorator with the last parameter will be executed first:

function f(key: string): any {
  console.log("evaluate: ", key);
  return function () {
    console.log("call: ", key);
  };
}

class C {
  method(
    @f("Parameter Foo") foo,
    @f("Parameter Bar") bar
  ) {}
}

The printed result of the code here is:

evaluate:  Parameter Foo
evaluate:  Parameter Bar
call:  Parameter Bar
call:  Parameter Foo

Combination of multiple decorators

You can apply multiple decorators to the same target. Their combination order is:

  1. Evaluate outer decorator
  2. Evaluate inner decorator
  3. Call inner decorator
  4. Call outer decorator

For example:

function f(key: string) {
  console.log("evaluate: ", key);
  return function () {
    console.log("call: ", key);
  };
}

class C {
  @f("Outer Method")
  @f("Inner Method")
  method() {}
}

The printed result of the code here is:

evaluate: Outer Method
evaluate: Inner Method
call: Inner Method
call: Outer Method

definition

Class decorator

Type declaration:

type ClassDecorator = <TFunction extends Function>
  (target: TFunction) => TFunction | void;
  • @Parameters:
    1. target: constructor of the class.
  • @Return:
    If the class decorator returns a value, it will be used to replace the declaration of the original class constructor.

Therefore, the class decorator is suitable for inheriting an existing class and adding some properties and methods.

For example, we can add a toString method to all classes to override its original toString method.

type Consturctor = { new (...args: any[]): any };

function toString<T extends Consturctor>(BaseClass: T) {
  return class extends BaseClass {
    toString() {
      return JSON.stringify(this);
    }
  };
}

@toString
class C {
  public foo = "foo";
  public num = 24;
}

console.log(new C().toString())
// -> {"foo":"foo","num":24}

Unfortunately, the decorator has no type protection, which means:

declare function Blah<T>(target: T): T & {foo: number}

@Blah
class Foo {
  bar() {
    return this.foo; // Property 'foo' does not exist on type 'Foo'
  }
}

new Foo().foo; // Property 'foo' does not exist on type 'Foo'

This is A known flaw in TypeScript . At present, all we can do is provide an additional class to provide type information:

declare function Blah<T>(target: T): T & {foo: number}

class Base {
  foo: number;
}

@Blah
class Foo extends Base {
  bar() {
    return this.foo;
  }
}

new Foo().foo;

Attribute decorator

Type declaration:

type PropertyDecorator =
  (target: Object, propertyKey: string | symbol) => void;
  • @Parameters:
    1. target: for static members, it is the constructor of the class, and for instance members, it is the prototype chain of the class.
    2. propertyKey: the name of the property.
  • @Return:
    The returned results will be ignored.

In addition to collecting information, property decorators can also be used to add additional methods and properties to classes. For example, we can write a decorator to add listeners to some properties.

function capitalizeFirstLetter(str: string) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

function observable(target: any, key: string): any {
  // prop -> onPropChange
  const targetKey = "on" + capitalizeFirstLetter(key) + "Change";

  target[targetKey] =
    function (fn: (prev: any, next: any) => void) {
      let prev = this[key];
      Reflect.defineProperty(this, key, {
        set(next) {
          fn(prev, next);
          prev = next;
        }
      })
    };
}

class C {
  @observable
  foo = -1;

  @observable
  bar = "bar";
}

const c = new C();

c.onFooChange((prev, next) => console.log(`prev: ${prev}, next: ${next}`))
c.onBarChange((prev, next) => console.log(`prev: ${prev}, next: ${next}`))

c.foo = 100; // -> prev: -1, next: 100
c.foo = -3.14; // -> prev: 100, next: -3.14
c.bar = "baz"; // -> prev: bar, next: baz
c.bar = "sing"; // -> prev: baz, next: sing

Method decorator

Type declaration:

type MethodDecorator = <T>(
  target: Object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<T>
) => TypedPropertyDescriptor<T> | void;
  • @Parameters:
    1. target: for static members, it is the constructor of the class, and for instance members, it is the prototype chain of the class.
    2. propertyKey: the name of the property.
    3. descriptor: of attribute Descriptor.
  • @Return: if a value is returned, it will be used to replace the descriptor of the property.

The difference between the method decorator and the attribute decorator lies in the descriptor parameter. Through this parameter, we can modify the original implementation of the method and add some common logic. For example, we can add the ability of printing input and output to some methods:

function logger(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;

  descriptor.value = function (...args) {
    console.log('params: ', ...args);
    const result = original.call(this, ...args);
    console.log('result: ', result);
    return result;
  }
}

class C {
  @logger
  add(x: number, y:number ) {
    return x + y;
  }
}

const c = new C();
c.add(1, 2);
// -> params: 1, 2
// -> result: 3

Accessor decorator

The accessor decorator is generally close to the method decorator. The only difference is that some key s in the descriptor are different:

The key of the descriptor of the method decorator is:

  • value
  • writable
  • enumerable
  • configurable

The descriptor key of accessor decorator is:

  • get
  • set
  • enumerable
  • configurable

For example, we can set an attribute to an immutable value:

function immutable(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.set;

  descriptor.set = function (value: any) {
    return original.call(this, { ...value })
  }
}

class C {
  private _point = { x: 0, y: 0 }

  @immutable
  set point(value: { x: number, y: number }) {
    this._point = value;
  }

  get point() {
    return this._point;
  }
}

const c = new C();
const point = { x: 1, y: 1 }
c.point = point;

console.log(c.point === point)
// -> false

Parameter decorator

Type declaration:

type ParameterDecorator = (
  target: Object,
  propertyKey: string | symbol,
  parameterIndex: number
) => void;
  • @Parameters:
    1. target: for static members, it is the constructor of the class, and for instance members, it is the prototype chain of the class.
    2. propertyKey: the name of the property (note that it is the name of the method, not the name of the parameter).
    3. parameterIndex: the subscript of the position of the parameter in the method.
  • @Return:
    The returned value will be ignored.

A single parameter decorator can do very limited things. It is generally used to record information that can be used by other decorators.

combination

For some complex scenes, we may need to combine different decorators. For example, if we want to add not only static checking to our interface, but also the ability of runtime checking.

We can realize this function in three steps:

  1. Mark the parameters that need to be checked (because the parameter decorator executes before the method decorator).
  2. Change the value of the descriptor of the method, run the parameter checker first, and throw an exception if it fails.
  3. Run the original interface implementation.

Here are the codes:

type Validator = (x: any) => boolean;

// save the marks
const validateMap: Record<string, Validator[]> = {};

// 1. Mark the parameters to be checked
function typedDecoratorFactory(validator: Validator): ParameterDecorator {
  return (_, key, index) => {
    const target = validateMap[key as string] ?? [];
    target[index] = validator;
    validateMap[key as string] = target;
  }
}

function validate(_: Object, key: string, descriptor: PropertyDescriptor) {
  const originalFn = descriptor.value;
  descriptor.value = function(...args: any[]) {

    // 2. Operation checker
    const validatorList = validateMap[key];
    if (validatorList) {
      args.forEach((arg, index) => {
        const validator = validatorList[index];

        if (!validator) return;

        const result = validator(arg);

        if (!result) {
          throw new Error(
            `Failed for parameter: ${arg} of the index: ${index}`
          );
        }
      });
    }

    // 3. Run the original method
    return originalFn.call(this, ...args);
  }
}

const isInt = typedDecoratorFactory((x) => Number.isInteger(x));
const isString = typedDecoratorFactory((x) => typeof x === 'string');

class C {
  @validate
  sayRepeat(@isString word: string, @isInt x: number) {
    return Array(x).fill(word).join('');
  }
}

const c = new C();
c.sayRepeat('hello', 2); // pass
c.sayRepeat('', 'lol' as any); // throw an error

As the example shows, it is important for us to understand the execution sequence and responsibilities of different kinds of decorators at the same time.

metadata

Strictly speaking, metadata and decorator are two separate parts of EcmaScript. However, if you want to achieve something like reflex Such abilities, you always need them at the same time.

What if we go back to the previous example, and we don't want to write different inspectors? Or, can we just write a checker that can automatically run type checking through the TS type declaration we write?

Yes reflect-metadata With the help of, we can get the compile time type.

import 'reflect-metadata';

function validate(
  target: Object,
  key: string,
  descriptor: PropertyDescriptor
) {
  const originalFn = descriptor.value;

  // Gets the compile time type of the parameter
  const designParamTypes = Reflect
    .getMetadata('design:paramtypes', target, key);

  descriptor.value = function (...args: any[]) {
    args.forEach((arg, index) => {

      const paramType = designParamTypes[index];

      const result = arg.constructor === paramType
        || arg instanceof paramType;

      if (!result) {
        throw new Error(
          `Failed for validating parameter: ${arg} of the index: ${index}`
        );
      }
    });

    return originalFn.call(this, ...args);
  }
}

class C {
  @validate
  sayRepeat(word: string, x: number) {
    return Array(x).fill(word).join('');
  }
}

const c = new C();
c.sayRepeat('hello', 2); // pass
c.sayRepeat('', 'lol' as any); // throw an error

So far, there are three compile time types available:

  • design:type: the type of the attribute.
  • desin:paramtypes: type of parameter of the method.
  • design:returntype: the type of the return value of the method.

The results of these three methods are constructors (such as String and Number). The rules are:

  • number -> Number
  • string -> String
  • boolean -> Boolean
  • void/null/never -> undefined
  • Array/Tuple -> Array
  • Class - > constructor of class
  • Enum - > if it is a pure numeric enumeration, it is Number; otherwise, it is Object
  • Function -> Function
  • The rest are objects

When to use?

Now we can draw conclusions about when to use decorators, and you may feel it in reading the code above.

I will cite some common usage scenarios:

  • Before/After hook.
  • Listen for property changes or method calls.
  • Convert the parameters of the method.
  • Add additional methods and properties.
  • Runtime type check.
  • Automatic codec.
  • Dependency injection.

I hope that after reading this article, you can find more usage scenarios for decorators and use it to simplify your code.

Topics: Javascript TypeScript