This post was originally posted on my personal blog, but you can also read it for a better reading experience.

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

An overview of

Decorators are essentially special functions that are used to:

  1. class
  2. Class attribute
  3. Class method
  4. Class accessor
  5. Class method parameters

So applying decorators is really a lot like composing a series of functions, similar to higher-order functions and classes. Decorators make it easy to implement the proxy pattern to make code simpler and achieve other more interesting capabilities.

The syntax for decorators is very simple. Just precede the desired decorator with the @ symbol and it will be applied to the target:

function simpleDecorator() {
  console.log('---hi I am a decorator---')}@simpleDecorator
class A {}
Copy the code

There are five decorators 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 decorators:

// Class decorator
@classDecorator
class Bird {

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

perform

The timing

Decorators are only applied once when the explanation is executed, for example:

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

@f
class A {}

// output: apply decorator
Copy the code

The code here prints the Apply Decorator on the terminal, even though we’re not actually using class A.

Execution order

The order of execution for the different types of decorators is clearly defined:

  1. Instance member:

Parameter Decorator -> Method/Accessor/Property Decorator 2. Static Member: Parameter Decorator -> Method/Accessor/Property 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")
  staticprop? :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;
}
Copy the code

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
Copy the code

You may notice that the instance property prop is executed later than the instance method method whereas 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, decorators with different parameters in the same method are executed in reverse order, with the decorator with the last parameter being 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
  ){}}Copy the code

The code here prints:

evaluate:  Parameter Foo
evaluate:  Parameter Bar
call:  Parameter Bar
call:  Parameter Foo
Copy the code

A combination of decorators

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

  1. Evaluates the outer decorator
  2. Evaluates the inner decorator
  3. Invoke the inner decorator
  4. Invoke the outer decorator

Such as:

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

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

The code here prints:

evaluate: Outer Method
evaluate: Inner Method
call: Inner Method
call: Outer Method
Copy the code

define

Class decorator

Type declaration:

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

Therefore, class decorators are suitable for inheriting an existing class and adding properties and methods.

For example, we could add a toString method to all classes to override the 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}
Copy the code

Unfortunately, decorators don’t have 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'
Copy the code

This is a known TypeScript flaw. All we can do for now 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;
Copy the code

Attribute decorator

Type declaration:

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

In addition to being used to gather information, attribute decorators can also be used to add additional methods and attributes to a class. 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
Copy the code

Method decorator

Type declaration:

type MethodDecorator = <T>(
  target: Object,
  propertyKey: string | symbol,
  descriptor: TypedPropertyDescriptor<T>
) = > TypedPropertyDescriptor<T> | void;
Copy the code
  • @ parameters:
    1. target: is the constructor of the class for static members and the prototype chain of the class for instance members.
    2. propertyKey: The name of the property.
    3. descriptorAttributes of the:The descriptor.
  • @ Return: If a value is returned, it is used instead of the descriptor for the property.

Method decorators differ from property decorators in the descriptor argument. With this parameter we can modify the method’s original implementation to add some common logic. For example, we can add the ability to print inputs and outputs 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);
    returnresult; }}class C {
  @logger
  add(x: number, y:number ) {
    returnx + y; }}const c = new C();
c.add(1.2);
// -> params: 1, 2
// -> result: 3
Copy the code

Accessor decorator

Accessor decorators are generally similar to method decorators, with the only difference being the key in the descriptor:

The descriptor key for the method decorator is:

  • value
  • writable
  • enumerable
  • configurable

Accessor decorator descriptor key is:

  • get
  • set
  • enumerable
  • configurable

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

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
Copy the code

Parameter decorator

Type declaration:

type ParameterDecorator = (
  target: Object,
  propertyKey: string | symbol,
  parameterIndex: number
) = > void;
Copy the code
  • @ parameters:
    1. target: is the constructor of the class for static members and the prototype chain of the class for instance members.
    2. propertyKey: Attribute name (method name, not parameter name).
    3. parameterIndex: The subscript of the argument’s position in a method.
  • @ Return: The returned value will be ignored.

A parameter decorator alone can do very little; it is generally used to record information that can be used by other decorators.

In combination with

For some complex scenarios, we may need to use a combination of different decorators. For example, if we want to add not only static checking to our interface, but also the ability to run time checking.

We can do this in 3 steps:

  1. Mark the parameters to check (because the parameter decorator executes before the method decorator).
  2. Change of approachdescriptorthevalueRun the parameter checker first, and throw an exception if it fails.
  3. Run the original interface implementation.

Here’s the code:

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

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

// 1. Mark the parameters to check
function typedDecoratorFactory(validator: Validator) :ParameterDecorator {
  return (_, key, index) = > {
    const target = validateMap[key as string]???? []; target[index] = validator; validateMap[keyas string] = target; }}function validate(_ :Object, key: string, descriptor: PropertyDescriptor) {
  const originalFn = descriptor.value;
  descriptor.value = function(. args:any[]) {

    // 2. Run inspector
    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 old 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
Copy the code

As the example shows, it is important for us to understand both the order of execution and the responsibilities of the different types of decorators.

metadata

Strictly speaking, metadata and decorators are two separate parts of EcmaScript. However, if you want to achieve something like reflex, you always need both.

If we go back to the last example, what if we didn’t want to write all kinds of different inspectors? Or, can we just write a checker that can automatically run type checking through the TS type declarations we write?

With reflect-Metadata help, we can get the compile-time type.

import 'reflect-metadata';

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

  // Get 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
Copy the code

So far there are three types of compile time available:

  • design:type: The type of the attribute.
  • desin:paramtypes: The type of the method’s argument.
  • design:returntype: The type of the return value of the method.

The results obtained in all three ways are constructors (such as String and Number). The rule is:

  • number -> Number
  • string -> String
  • boolean -> Boolean
  • void/null/never -> undefined
  • Array/Tuple -> Array
  • Class -> Class constructor
  • Enum -> In the case of a purely numeric enumerationNumber, it isObject
  • Function -> Function
  • The rest areObject

When to use it?

Now we can draw conclusions about when to use decorators, and you might get a sense of that from reading the code above.

Here are some examples of common usage scenarios:

  • Before/After the hook.
  • Listen for property changes or method calls.
  • Convert method parameters.
  • Add additional methods and attributes.
  • Runtime type checking.
  • Automatic encoding and decoding.
  • Dependency injection.

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