TS 5.0 Beta: New Decorators Are Here!

TypeScript 5.0 Decorator, TypeScript ES Decorator, TypeScript Decorator, TypeScript Decorators, How To Use Decorators in TypeScript, Decorator in TypeScript, The Magic of TypeScript Decorators, typescript decorators guide, TypeScript 5.0, TS 5.0

image

Photo by Dawid Zawiła on Unsplash

Welcome to the Mastering TypeScript series. This series will introduce the core knowledge and techniques of TypeScript. Let’s learn together! Previous articles are as follows:

type Decorator = (
  value: Input,
  context: {
    kind: string;
    name: string | symbol;
    access: {
      get?(): unknown;
      set?(value: unknown): void;
    };
    private?: boolean;
    static?: boolean;
    addInitializer?(initializer: () => void): void;
  }
) => Output | void;

The new version of the decorator function supports value and context two parameters. Input and Output represent the type of the value input parameter and the type of the function return value, respectively. Each type of decorator has different inputs and outputs, and the value of the context object also depends on the object being decorated. The relevant description of the context object is as follows:

  • kind: The type of decorated value, which can be used to ensure that the decorator is used correctly. Its value may be “class”, “method”, “getter”, “setter”, “field” or “accessor”.
  • name: The name of the value being decorated, such as a class name, property name, or method name.
  • access: An object contains get and set methods to access the value.
  • private: This value indicates whether it is a private class member.
  • static: This value indicates whether it is a static class member.
  • addInitializer: Allows the user to add additional initialization logic. The addInitializer method above allows us to add custom initialization logic to achieve corresponding functions. For example, customize a method decorator to realize the function of binding this object for a certain method in the class. Regarding the related content of the addInitializer method, I will introduce it separately in the next part. Next, let’s build a local development environment for the new version of the decorator.
mkdir es-decorator
npm init -y
npm install typescript@beta -D

After the above command is successfully executed, an es-decorator directory will be created and typescript@beta dependencies will be installed. After setting up the development environment, let’s introduce the class decorator first.

Class Decorators

Class decorators are used to decorating classes, and their corresponding types are declared as follows:

type ClassDecorator = (
  value: Function,
  context: {
    kind: "class";
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

For a class decorator, it receives two parameters value and context, where the value of the kind property of the context parameter object is “class” . The new version of the class decorator is different from the old version of the class decorator. The difference between them is shown in the following figure: image From the figure above, we can see that the class decorator function of the old version contains only one target parameter. After understanding the differences between different versions, let’s create a @Greeter class decorator, which is used to dynamically add a greet method to the decorated class:

function Greeter(value, context) {
  if (context.kind === "class") {
    return class extends value {
      constructor(...args) {
        super(args);
      }
      greet(): void {
        console.log("Hello Bytefer!");
      }
    };
  }
}

@Greeter
class User {}

let bytefer = new User();
(bytefer as any).greet();

In the above code, we use the expression context.kind === “class” to ensure that the @Greeterclass decorator only takes effect on the class. Afterward, in the @Greeterclass decorator, we add the greet method to the subclass through inheritance and return the subclass. But since the TypeScript version I installed locally is 4.9.5, the above code will report an error, and the corresponding error message is shown in the figure below: image

To solve the above problem, we need to switch the version of TypeScript. Here I am using the Mac version of the VSCode editor. The command window can be invoked by the shortcut keys Command + Shift + P, and then you can select the TypeScript version used by the project. This project needs to be switched to 5.0.0-beta. image

After the version switching is successful, the error message disappears automatically. Through the npx tsccommand, you can use the new version of the TypeScript compiler installed in the project to compile the ts file, and after generating the corresponding js file, you can use node to execute the code. When you successfully run the above code, the console will output “Hello Bytefer!”. In addition to using inheritance, we can also use prototype objects to dynamically add new methods.

function Greeter(value, context) {
  if (context.kind === "class") {
    value.prototype.greet = function () {
      console.log("Hello Bytefer!");
    };
  }
}
Decorator Factory

The role of the decorator factory is to produce decorators, and its essence is a factory function. After calling the factory function, the decorator function will be returned, so the decorator factory is a higher-order function. In mathematics and computer science, a higher-order function is a function that satisfies at least one of the following conditions: (1) accepts one or more functions as input; (2) outputs a function. After learning about the decorator factory, let’s update the Greeter function defined earlier:

function Greeter(msg: string) {
  return function (value, context) {
    if (context.kind === "class") {
      return class extends value {
        constructor(...args) {
          super(args);
        }
        greet(): void {
          console.log(msg);
        }
      };
    }
  };
}

With the Greeter factory function in place, we can use it like this:

@Greeter("Hello ES Decorator!")
class User {}

let bytefer = new User();
(bytefer as any).greet();

When you successfully run the above code, the console will output “Hello ES Decorator!”. We can actually add multiple class decorators to the same class. For example, in the following code, I added a @Log class decorator:

function Log(value, context) {
  if (context.kind === "class") {
    return class extends value {
      constructor(...args) {
        super(args);
      }
      log(msg: string): void {
        console.log(msg);
      }
    };
  }
}

@Log
@Greeter("Hello ES Decorator!")
class User {}

let bytefer = new User();
(bytefer as any).greet();
(bytefer as any).log("Hello Bytefer!");

In the above code, we have added two decorators to the User class, after which we can call the greet and log methods on the User instance.

"Hello ES Decorator!"
"Hello Bytefer!"

Everything looks good so far, but if the User class that adds the decorator contains certain properties or methods, the TypeScript compiler will report an error, and the corresponding error message is shown in the following figure: image This is because the type of the value parameter is of type any, to fix this we need to explicitly set the type of the value parameter:

function Log<Input extends new (...args: any) => any>(
  value: Input,
  context: ClassDecoratorContext
) {
  if (context.kind === "class") {
    return class extends value {
      constructor(...args) {
        super(args);
      }
      log(msg: string): void {
        console.log(msg);
      }
    };
  }
}

In the above @Log decorator, we introduced the generic variable Input and used extends to constrain its type, and then used it as the type of the value parameter, thus solving the problems encountered earlier. You can read the following article if you don’t know about generic variables.

Property/Field Decorators

The attribute decorator is used to decorate the attributes of the class, and its corresponding type is declared as follows:

type ClassFieldDecorator = (
  value: undefined,
  context: {
    kind: "field";
    name: string | symbol;
    access: { get(): unknown; set(value: unknown): void };
    static: boolean;
    private: boolean;
  }
) => (initialValue: unknown) => unknown | void;

The property decorator, also receives two parameters value and context, where the value of the kind property of the context parameter object is “field”. The property decorator of the new version is also different from the property decorator of the old version. The difference between them is shown in the following figure: image According to the definition of the property decorator, let’s create a @logged property decorator to track the initialization process of the property.

function logged(value, context) {
  const { kind, name } = context;
  if (kind === "field") {
    return function (initialValue) {
      console.log(`initializing ${name} with value ${initialValue}`);
      return initialValue;
    };
  }
}

In the @logged decorator, we pass the kind === “field” expression to ensure that the @loggeddecorator only takes effect on the properties of the class. An anonymous function is returned when the if(kind === “field”) statement condition is true. The function receives one parameter representing the initial value of the decorated property. Here we simply output the initialization information of the attribute, of course, you can also modify the initial value of the attribute in the returned anonymous function. With the @logged property decorator, we can use it on the properties of the class. For example, we use this property decorator on the name property of the User class:

class User {
  @logged
  public name: string = "bytefer";
}

const user = new User();
console.log(user.name);

After successfully running the above code, the console will output the following results:

"initializing name with value bytefer"
"bytefer"

In addition to decorating the properties of the class, we can also decorate the methods of the class.

Method Decorator

The method decorator is used to decorate the method of the class, and its corresponding type declaration is as follows:

type ClassMethodDecorator = (
  value: Function,
  context: {
    kind: "method";
    name: string | symbol;
    access: { get(): unknown };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

The method decorator also receives two parameters value and context, where the value of the kind property of the context parameter object is “method”. The method decorator of the new version is also different from the method decorator of the old version. The difference between them is shown in the following figure: image According to the definition of method decorator, let’s create a @logMethod method decorator to track the invocation of member methods in a class.

function logMethod(value, context) {
  const { kind, name } = context;
  const methodName = String(name);

  if (kind === "method") {
    return function (...args: any[]) {
      console.log(`Before invoking method: ${methodName}`);
      let result = value.apply(this, args);
      console.log(`After invoking method: ${methodName}`);
      return result;
    };
  }
}

With the @logMethod method decorator, we can use it on the member methods of the class. For example, we use the @logMethod method decorator on the greet member method of the User class:

class User {
  @logMethod
  greet(msg: string): string {
    return `Hello ${msg}!`;
  }
}

let user = new User();
let msg = user.greet("Bytefer");
console.log(msg);

After successfully running the above code, the console will output the following results:

Before invoking method: greet
After invoking method: greet
Hello Bytefer!

After understanding how to create method decorators, we can create some method decorators that are more useful in our work. For example, method decorators that implement delayed execution or throttling. @delay

function delay(milliseconds: number = 0) {
  return function (value, context) {
    if (context.kind === "method") {
      return function (...args: any[]) {
        setTimeout(() => {
          value.apply(this, args);
        }, milliseconds);
      };
    }
  };
}

class Logger {
  @delay(1000)
  log(msg: string) {
    console.log(`${msg}`);
  }
}

let logger = new Logger();
logger.log("Hello Bytefer!");

@throttle Before creating the @throttle method decorator, let’s define a throttleFn function. Of course, you can also directly use the lodash.throttle function provided by the lodash library.

function throttleFn(fn: Function, wait: number = 300) {
  let inThrottle: boolean,
    lastFn: ReturnType<typeof setTimeout>,
    lastTime: number;
  return function (this: any) {
    const context = this,
      args = arguments;
    if (!inThrottle) {
      fn.apply(context, args);
      lastTime = Date.now();
      inThrottle = true;
    } else {
      clearTimeout(lastFn);
      lastFn = setTimeout(() => {
        if (Date.now() - lastTime >= wait) {
          fn.apply(context, args);
          lastTime = Date.now();
        }
      }, Math.max(wait - (Date.now() - lastTime), 0));
    }
  };
}

With the throttleFn function, we can create the @throttle decorator based on it:

function throttle(milliseconds: number = 300): any {
  return function (value, context) {
    if (context.kind === "method") {
      return throttleFn(value, milliseconds);
    }
  };
}

class Logger {
  @throttle(1000)
  log(msg: string) {
    console.log(`${msg}`);
  }
}

let logger = new Logger();
logger.log("Hello Bytefer!");
logger.log("Hello Bytefer!");
logger.log("Hello Bytefer!");
logger.log("Hello Bytefer!");

After reading the concrete implementation of @delay and @throttle method decorators, you can write a method decorator by hand. For example, implement a @deprecated method decorator to indicate that a method in the class is about to be removed or deprecated.

Class Accessors

In addition to ordinary methods, a class may also contain special setter or getter methods. We can also develop corresponding decorators for setter/getter methods. Before developing specific setter/getter decorators, let’s take a look at their type definitions. ClassSetterDecorator

type ClassSetterDecorator = (
  value: Function,
  context: {
    kind: "setter";
    name: string | symbol;
    access: { set(value: unknown): void };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

ClassGetterDecorator

type ClassGetterDecorator = (
  value: Function,
  context: {
    kind: "getter";
    name: string | symbol;
    access: { get(): unknown };
    static: boolean;
    private: boolean;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

It should be noted that context.access is currently disabled in the TS 5.0 Beta, waiting for context.access API implementation feedback Issue feedback results. After understanding the type definitions of ClassSetterDecorator and ClassGetterDecorator, let’s extend the @logMethod method decorator created earlier to support the call of the setter/getter method in the tracking class.

function logMethod(value, context) {
  const { kind, name } = context;
  const methodName = String(name);

  if (kind === "method" || kind === "getter" || kind === "setter") {
    return function (...args: any[]) {
      console.log(`Before invoking ${kind}: ${methodName}`);
      let result = value.apply(this, args);
      console.log(`After invoking ${kind}: ${methodName}`);
      return result;
    };
  }
}

Based on the original @logMethod method decorator, we have added the handling of getter and setter types. With the new @logMethod method decorator, let’s verify its functionality:

class User {
  public _age: number;
  constructor(public name: string) {}

  @logMethod
  set age(age: number) {
    if (age < 0 || age > 120) {
      throw new Error("The value of age is invalid"!);
    }
    this._age = age;
  }

  @logMethod
  get age() {
    return this._age;
  }
}

const user = new User("Bytefer");
user.age = 36;
console.log(user.age);

After successfully running the above code, the console will output the following results:

"Before invoking setter: age"
"After invoking setter: age"
"Before invoking getter: age"
"After invoking getter: age"
36

Finally, let me introduce the addInitializer method in the context parameter object of the decorator function.

addInitializer

In addition to class field decorator, the context parameter objects of different types of decorator functions introduced in this article all provide the addInitializer method externally. Calling this method can associate the initialization function with the class or class members, and can be used to run arbitrary code after the value is defined in order to complete the initialization settings.

type ClassFieldDecorator = (
  value: undefined,
  context: {
    kind: "field";
    name: string | symbol;
    access: { get(): unknown; set(value: unknown): void };
    static: boolean;
    private: boolean;
  }
) => (initialValue: unknown) => unknown | void;

Note that the runtime point of the initializer depends on the type of decorator:

  • The initializer of a class decorator is run after the class is fully defined and the class’s static fields are assigned values.
  • Class member initializers run during class construction before class fields are initialized.
  • Class static member initializers run during class definition before static fields are defined, but after class members are defined. After understanding the function of the addInitializer method, let’s take a look at how to use it to solve the problems that may be encountered in the work:
class User {
  constructor(public name: string) {}

  say(msg: string) {
    console.log(`${this.name} says: ${msg}`);
  }
}

const bytefer = new User("Bytefer");
const say = bytefer.say;
say("Hello ES Decorator!");

After successfully running the above code, the console will output the following results:

"undefined says: Hello ES Decorator!"

Obviously, this is not what we want, because of the binding of this. A common solution to this problem is to manually bind the this object to the say method in the constructor of the class. The specific processing method is as follows:

class User {
  constructor(public name: string) {
    this.say = this.say.bind(this);
  }

  say(msg: string) {
    console.log(`${this.name} says: ${msg}`);
  }
}

In addition to the above solutions, we can also develop a @bound decorator to realize the function of automatically binding this object.

function bound(value, context) {
  const { kind, name, addInitializer } = context;
  if (kind === "method") {
    addInitializer(function () {
      this[name] = this[name].bind(this);
    });
  }
}

In the bound function, we get the addInitializer method from the context object, and then use this method to add an anonymous function, inside which we bind this for the class method decorated by the decorator The function of the object. After that, we can add the @bound decorator on the say method:

class User {
  constructor(public name: string) {}

  @bound
  say(msg: string) {
    console.log(`${this.name} says: ${msg}`);
  }
}

const bytefer = new User("Bytefer");
const say = bytefer.say;
say("Hello ES Decorator!");

After successfully running the above code, the console will output the following results:

"Bytefer says: Hello ES Decorator!"

According to the type definition of the class decorator, we also provide with the addInitializer method in the context parameter object of the class decorator function:

type ClassDecorator = (
  value: Function,
  context: {
    kind: "class";
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

Using the addInitializer method, we can implement some useful decorators. For example, implementing a @customElement decorator for automatic registration of custom components conforming to the Web Components standard:

function customElement(name: string) {
  return <Input extends new (...args: any) => any>(
    value: Input,
    context: ClassDecoratorContext
  ) => {
    context.addInitializer(function () {
      customElements.define(name, value);
    });
  };
}

@customElement("hello-world")
class MyComponent extends HTMLElement {
  constructor() {
    super();
  }
  connectedCallback() {
    this.innerHTML = `<h1>Hello World</h1>`;
  }
}

The new version of the decorator brings a lot of new features. In addition to the content introduced in the article, it also includes decorators supporting private properties and class expressions, Accessor Decorators, etc. If you are interested, you can read the article Implement the Stage 3 Decorators Proposal.

If you like to learn TypeScript, you can follow me on Medium or Twitter to read more about TS and JS!

Enjoyed this article?

Share it with your network to help others discover it

Continue Learning

Discover more articles on similar topics