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:
- class
- Class properties
- Class method
- Class accessor
- 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:
- Class decorator
- Attribute decorator
- Method decorator
- Accessor decorator
- 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:
- 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:
- Evaluate outer decorator
- Evaluate inner decorator
- Call inner decorator
- 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:
- 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:
- target: for static members, it is the constructor of the class, and for instance members, it is the prototype chain of the class.
- 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:
- target: for static members, it is the constructor of the class, and for instance members, it is the prototype chain of the class.
- propertyKey: the name of the property.
- 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:
- target: for static members, it is the constructor of the class, and for instance members, it is the prototype chain of the class.
- propertyKey: the name of the property (note that it is the name of the method, not the name of the parameter).
- 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:
- Mark the parameters that need to be checked (because the parameter decorator executes before the method decorator).
- Change the value of the descriptor of the method, run the parameter checker first, and throw an exception if it fails.
- 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.