Tags: TypeScript
Decorators allow us to add additional information to classes or methods in TypeScript, and are similar to annotations such as in Java. Method decorators are applied to methods defined in classes in TypeScript. With them we can record information about methods, or modify method execution.
In this article we will explore using and creating method decorators. Methods are of course the functions we attach to classes, or are inherited from superclasses.
To use decorators, they must be enabled in TypeScript, so be sure to review the introduction to decorators article in this series.
In practice method decorators look like this:
class A {
@MethodDecorator()
fly(meters: number) {
// code
}
}
Method decorators apply to the method, and not to parameters to the function. Parameter decorators are a different thing which we covered elsewhere.
This article is part of a series:
- Introduction to Decorators
- Class Decorators
- Property Decorators
- Accessor Decorators
- Parameter Decorators
- Method Decorators This article
- Hybrid Decorators
- Using Reflection and Reflection API with Decorators
- Runtime data validation using Decorators
To use decorators, two features must be enabled in TypeScript, so be sure to review the introduction to decorators article in this series.
Method decorator functions in TypeScript
Method decorators are invoked just before the method is instantiated. The parameters passed to these functions are:
- Either the constructor function of the class for a static member, or the prototype of the class for an instance member.
- A string giving the name of the property
- The PropertyDescriptor of the member - the function
The first two decorator function parameters are the same as for several other decorator types. The PropertyDescriptor is the same object is used in some other decorator types, but is used slightly differently. JavaScript fills in this object differently than it does for accessors.
Exploring Method Decorators in TypeScript
For the first step, let's create a decorator to print the parameter values.
import * as util from 'util';
function logMethod(target: Object, propertyKey: string,
descriptor: PropertyDescriptor) {
console.log(`logMethod`, {
target, propertyKey, descriptor,
targetKeys: Object.getOwnPropertyNames(target),
function: descriptor.value,
funcText: descriptor.value.toString()
});
}
class MethodExample {
@logMethod
method(x: number) {
return x * 2;
}
}
This prints out the available data about the target, the descriptor, and the value
field in the descriptor. For targetKeys
we are interested in validating that target
is the class containing the method. For function
, we learned that the value
field contains the function, and that using toString
lets us see the text of the function.
To see the output:
$ npx ts-node lib/methods/methods.ts
logMethod {
target: {},
propertyKey: 'method',
descriptor: {
value: [Function: method],
writable: true,
enumerable: false,
configurable: true
},
targetKeys: [ 'constructor', 'method' ],
function: [Function: method],
funcText: 'method(x) {\n return x * 2;\n }'
}
Yes, the target
is clearly the class containing this method, and the value
field is the actual function.
Spying on method invocation
Because we have a PropertyDescriptor for the method, we can try to override the function. As we did with accessors, let's try a new decorator that lets us spy on the input and output values for the function.
function MethodSpy(target: Object,
propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`MethodSpy before ${propertyKey}`, args);
const result = originalMethod.apply(this, args);
console.log(`MethodSpy after ${propertyKey}`, result);
return result;
}
}
class SpiedOn {
@MethodSpy
area(width: number, height: number) {
return width * height;
}
@MethodSpy
areaCircle(diameter: number) {
return Math.PI * ((diameter / 2) ** 2);
}
}
const spyon = new SpiedOn();
console.log(spyon.area(6, 10));
console.log(spyon.area(16, 20));
console.log(spyon.areaCircle(10));
console.log(spyon.areaCircle(20));
Inside the function, we save the original value for descriptor.value
since that's the actual member function. We replace this with another function which takes any number of arguments. Remember that ...args
becomes an array containing the arguments passed to the function. We first print the function name, and the supplied arguments. We then call the original method, supplying the arguments, and capture the result. We then print the function name, and the result, and finally return the result.
And then run the script:
$ npx ts-node lib/methods/spy.ts
MethodSpy before area [ 6, 10 ]
MethodSpy after area 60
60
MethodSpy before area [ 16, 20 ]
MethodSpy after area 320
320
MethodSpy before areaCircle [ 10 ]
MethodSpy after areaCircle 78.53981633974483
78.53981633974483
MethodSpy before areaCircle [ 20 ]
MethodSpy after areaCircle 314.1592653589793
314.1592653589793
In each case the value printed in the before stage is an array, and in the after stage is the expected result for each method.
Because of how it's written, we can easily apply this to any decorator to any method. It even automatically picks up the method name.
Method and Parameter decorators working together
We've suggested that the two types of decorators can work together to produce a useful result. The concept we want to try is a parameter decorator which can supply a default value if an optional parameter has not been supplied.
This is what that means:
class DefaultExample {
@SetDefaults
volume(
z: number,
@ParamDefault<number>(10) x?: number,
@ParamDefault<number>(15) y?: number,
title?: string
) {
const ret = {
x, y, z, volume: x * y * z, title
};
console.log(`volume `, ret);
return ret;
}
}
A class, with a method which computes the volume based on an x
, y
, and z
values. The @ParamDefault
decorator will let us specify a default value. Notice that the parameters have a ?
indicating that it's optional. Hence, we need some code to detect when a parameter has not been supplied, and make a substitution.
Since @ParamDefault
is a parameter decorator, it cannot do anything other than record its existence into an array, as we discussed while studying parameter decorators. To substitute the default value, we need some code which executes against instances of the class. We just showed that method decorators can override a method's function, for execution against class instances. The plan is to inject a function to be invoked before the actual function, and to use that injected function to set any default value.
Let's see how to implement all this, starting with ParamDefault
:
const paramDefaults = [];
...
function ParamDefault<T>(value: T) {
return (target: Object, propertyKey: string | symbol,
parameterIndex: number) => {
paramDefaults.push({
target, propertyKey, parameterIndex, value
});
}
}
This stores data about the parameter that is decorator, as well as the default value to use. The paramDefaults
array stores this data.
function findDefaults(target: Object, propertyKey: string) {
const ret = [];
for (const def of paramDefaults) {
if (target === def.target && propertyKey === def.propertyKey) {
ret.push(def);
}
}
return ret;
}
function SetDefaults(target: Object, propertyKey: string,
descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`SetDefaults before ${propertyKey}`, args);
for (const def of findDefaults(target, propertyKey)) {
if (typeof args[def.parameterIndex] === 'undefined'
|| args[def.parameterIndex] === null) {
args[def.parameterIndex] = def.value;
}
}
console.log(`SetDefaults after substitution ${propertyKey}`, args);
const result = originalMethod.apply(this, args);
console.log(`SetDefaults after ${propertyKey}`, result);
return result;
}
}
The SetDefaults
decorator function installs a function that will handle substituting values based on the defaults which were declared. The findDefaults
function searches in paramDefaults
for all items matching the target
and propertyKey
. Having selected the defaults, we can look in the actual arguments to see if any were not provided - which we define as either undefined
or null
. If the argument is not given, then make the substitution, then call the original function, and return the result.
Getting back to our example, there are four combinations to try:
- Both the
x
andy
values not supplied. - Only
x
not supplied. - Only
y
not supplied. - Both
x
andy
supplied.
This handles all four possibilties for substituting values for missing parameters. Additionally, we added another parameter, title, where we do not supply a default, to verify the lack of substitution. Finally, the parameter z
is there as a non-optional parameter to make sure such values are handled correctly.
This constitutes our test code:
const de = new DefaultExample();
// both x and y missing
console.log(de.volume(10));
console.log('----------------------');
// only x missing
console.log(de.volume(20, null, 20, "Second"));
console.log('----------------------');
// only y missing
console.log(de.volume(30, 30, null));
console.log('----------------------');
// both x and y supplied
console.log(de.volume(40, 40, 50, "Fourth"));
That handles the four scenarios just mentioned, and makes sure to pass a different value for each parameter to ensure there's no bleeding of values between invocations.
The result looks like this:
$ npx ts-node lib/methods/defaults.ts
SetDefaults before volume [ 10 ]
SetDefaults after substitution volume [ 10, 10, 15 ]
volume { x: 10, y: 15, z: 10, volume: 1500, title: undefined }
SetDefaults after volume { x: 10, y: 15, z: 10, volume: 1500, title: undefined }
{ x: 10, y: 15, z: 10, volume: 1500, title: undefined }
----------------------
SetDefaults before volume [ 20, null, 20, 'Second' ]
SetDefaults after substitution volume [ 20, 10, 20, 'Second' ]
volume { x: 10, y: 20, z: 20, volume: 4000, title: 'Second' }
SetDefaults after volume { x: 10, y: 20, z: 20, volume: 4000, title: 'Second' }
{ x: 10, y: 20, z: 20, volume: 4000, title: 'Second' }
----------------------
SetDefaults before volume [ 30, 30, null ]
SetDefaults after substitution volume [ 30, 30, 15 ]
volume { x: 30, y: 15, z: 30, volume: 13500, title: undefined }
SetDefaults after volume { x: 30, y: 15, z: 30, volume: 13500, title: undefined }
{ x: 30, y: 15, z: 30, volume: 13500, title: undefined }
----------------------
SetDefaults before volume [ 40, 40, 50, 'Fourth' ]
SetDefaults after substitution volume [ 40, 40, 50, 'Fourth' ]
volume { x: 40, y: 50, z: 40, volume: 80000, title: 'Fourth' }
SetDefaults after volume { x: 40, y: 50, z: 40, volume: 80000, title: 'Fourth' }
{ x: 40, y: 50, z: 40, volume: 80000, title: 'Fourth' }
The dashed lines are there for clarity in reading the results.
Carefully go over these results and you see that all substitutions occurred as expected.
Summary
We've implemented a very interesting feature, the ability to substitute default values for missing method parameters, with a combination of method and parameter decorators. The parameter decorator function saved away data about default values, and the method decorator function detected missing parameters for which there is a default available.