Deep introduction to method decorators in TypeScript

; Date: Thu Feb 17 2022

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:

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:

  1. Either the constructor function of the class for a static member, or the prototype of the class for an instance member.
  2. A string giving the name of the property
  3. 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:

  1. Both the x and y values not supplied.
  2. Only x not supplied.
  3. Only y not supplied.
  4. Both x and y 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.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.

Books by David Herron

(Sponsored)