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 bindingthis
object for a certain method in the class. Regarding the related content of theaddInitializer
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:
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 @Greeter
class decorator only takes effect on the class. Afterward, in the @Greeter
class 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:
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.
After the version switching is successful, the error message disappears automatically. Through the npx tsc
command, 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:
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:
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 @logged
decorator 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:
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!