Implementing hybrid decorator functions in TypeScript

; Date: Sun Feb 20 2022

Tags: TypeScript

The documentation on TypeScript decorators describes function signatures for each decorator type. It implies that each decorator must be implemented for a specific target object type. But, with a carefully defined method signature we can construct decorators to handle decorating any object type.

In this article we'll talk about the various function signatures used in TypeScript decorator functions. By carefully examining the parameters, we can deduce what kind of object to which the decorator has been attached. This is because, if we look at the full list of decorator function signatures, we notice there is a pattern. We can then define type guard functions to detect which pattern is being used, and reliably know what object type to which the decorator has been attached.

With functions to test the function signature used to invoke the decorator function, we can determine what kind of object to which it is attached. That will allow a decorator to be used against any object type. Our goal for this article is a decorator that can be used in all five contexts: Class, Property, Accessor, Parameter, and Method

Such functions are available in the (www.npmjs.com) decorator-inspectors package.

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.

Overview of method signatures for TypeScript decorator functions

A review of the decorator function signatures used in other articles in this series gives us these signatures:

const accessorfunc = (target: Object,
                        propertyKey: string,
                        descriptor: PropertyDescriptor) => {};

const constructorfunc = (constructor: Function) => {};

const methodsfunc = (target: Object, propertyKey: string,
                    descriptor: PropertyDescriptor) => {};

const parametersfunc = (target: Object,
                        propertyKey: string | symbol,
                        parameterIndex: number) => {};

const propertiesfunc = (target: Object, member: string)  => {};

For instance, the class decorator takes only one argument, which could have been named target. The properties decorator takes only two arguments. The other three decorators take three arguments, there are differences between them. The parameters decorator takes a number in the third argument. The accessor and method decorators both take a PropertyDescriptor as the third argument, but there are differences in how that object is constructed.

A decorator attached to an accessor receives one set of parameters, while one attached to a class receives other parameters. A decorator function for every decorator type must be capable of handling any set of parameters, and distinguish between one use or another.

This means we need a common function signature that matches each decorator type.

After consideration and testing, this was determined to handle each case:

(target: Object, propertyKey?: string | symbol, descriptor?: number | PropertyDescriptor)

The target parameter is common to each, while the other two are optional. Then, for the other two there are variations in the value to accommodate. This signature handles all variants.

This will be the function signature for a decorator function that can be used for any of the five decorator types. The next thing required is functions similar to a type guard to test the parameters.

Remember that, in TypeScript, a type guard function takes a parameter, and tests that parameter to ensure it is of a certain type.

Test case for the concept of a hybrid decorator function

This class definition was created to test whether a hybrid decorator function is possible.

@Decorator
class HybridDecorated {
    @Decorator
    prop1: number;

    @Decorator
    prop2: string;

    @Decorator
    method(
        @Decorator param1: string,
        @Decorator param2: string
    ) {
        console.log(`inside method function`);
        return { param1, param2 };
    }

    #meaning: number = 42;

    @Decorator
    get meaning() { return this.#meaning; }
    set meaning(nm: number) { this.#meaning = nm; }
}

That requires a function, Decorator, which successfully detects which context it is in, or put another way which kind of object to which the decorator function has been attached.

Prototyping a decorator to handle all TypeScript decorator uses

Given the common function signauture shown earlier, the Decorator function must be defined this way:

function Decorator(target: Object, 
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) {

    console.log(`Decorator target`, target);
    console.log(`Decorator propertyKey`, propertyKey);
    console.log(`Decorator descriptor`, descriptor);
}

Indeed, if you construct a file (e.g. first.ts) containing the above class definition, and this Decorator implementation, you get this output:

$ npx ts-node lib/hybrid/first.ts
Decorator target {}
Decorator propertyKey prop1
Decorator descriptor undefined
Decorator target {}
Decorator propertyKey prop2
Decorator descriptor undefined
Decorator target {}
Decorator propertyKey method
Decorator descriptor 1
Decorator target {}
Decorator propertyKey method
Decorator descriptor 0
Decorator target {}
Decorator propertyKey method
Decorator descriptor {
  value: [Function: method],
  writable: true,
  enumerable: false,
  configurable: true
}
Decorator target {}
Decorator propertyKey meaning
Decorator descriptor {
  get: [Function: get meaning],
  set: [Function: set meaning],
  enumerable: false,
  configurable: true
}
Decorator target [class HybridDecorated]
Decorator propertyKey undefined
Decorator descriptor undefined

That proves the concept works, that a TypeScript decorator function can be successfully attached to all five decoratable object types.

Testing the object type to which a hybrid decorator is attached

The following functions come from the (www.npmjs.com) decorator-inspectors package.

To make these functions a little simpler, we start with this:

const isset = (val) => {
    return typeof val !== 'undefined' && val !== null;
};
const notset = (val) => {
    return (typeof val === 'undefined') || (val === null);
};

These are used to ensure that a parameter actually has a value. If it not undefined and not null then it has a value.

export const isClassDecorator = (target: Object,
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) => {

    return (isset(target)
         && notset(propertyKey)
         && notset(descriptor));
};

Class decorators only have a value in the first parameter.

export const isPropertyDecorator = (target: Object,
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) => {

    return (isset(target)
         && isset(propertyKey)
         && notset(descriptor));
};

Property decorators only have values in the first two parameters.

export const isParameterDecorator = (target: Object,
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) => {

    return (isset(target)
         && isset(propertyKey)
         && isset(descriptor)
         && typeof descriptor === 'number');
};

Parameter decorators have values in all three parameters, and the third is a number.

export const isMethodDecorator = (target: Object,
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) => {

    if ((isset(target)
     && isset(propertyKey)
     && isset(descriptor)
     && typeof descriptor === 'object')) {
        const propdesc = <PropertyDescriptor>descriptor;
        return (typeof propdesc.value === 'function');
    } else {
        return false;
    }
}

Method decorators have values in all three parameters, and the third is an object which is the PropertyDescriptor. This descriptor has a function stored in the value field.

export const isAccessorDecorator = (target: Object,
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) => {

    if ((isset(target)
     && isset(propertyKey)
     && isset(descriptor)
     && typeof descriptor === 'object')) {
        const propdesc = <PropertyDescriptor>descriptor;
        return (typeof propdesc.value !== 'function')
         && (typeof propdesc.get === 'function'
          || typeof propdesc.set === 'function');
    } else {
        return false;
    }
}

Accessor decorators are like method decorators. The difference is that the value field does not have a function, and the get and/or set fields do have functions.

Using the decorator type guards in a hybrid decorator function

Let's demonstrate how to construct a decorator function which handles all five scenarios using these functions:

import {
    isClassDecorator, isPropertyDecorator, isParameterDecorator,
    isMethodDecorator, isAccessorDecorator
} from 'decorator-inspectors';


function Decorator(target: Object, 
    propertyKey?: string | symbol,
    descriptor?: number | PropertyDescriptor) {

    if (isClassDecorator(target, propertyKey, descriptor)) {
        console.log(`Decorator called on class`, target);
    } else if (isPropertyDecorator(target, propertyKey, descriptor)) {
        console.log(`Decorator called on property ${target} ${String(propertyKey)}`);
    } else if (isParameterDecorator(target, propertyKey, descriptor)) {
        console.log(`Decorator called on parameter ${target} ${String(propertyKey)} ${descriptor}`);
    } else if (isMethodDecorator(target, propertyKey, descriptor)) {
        console.log(`Decorator called on method ${target} ${String(propertyKey)}`, descriptor);
    } else if (isAccessorDecorator(target, propertyKey, descriptor)) {
        console.log(`Decorator called on accessor ${target} ${String(propertyKey)}`, descriptor);
    }
    else {
        console.error(`Decorator called on unknown thing`, target);
        console.error(`Decorator called on unknown thing`, propertyKey);
        console.error(`Decorator called on unknown thing`, descriptor);
    }
}

This imports the functions from the decorator-inspectors package. The function, Decorator, uses the universal decorator method signature. It then uses the functions to determine which context it is used in, and prints an appropriate message. In case it is used incorrectly, the messages at the bottom will print.

If we use this decorator function with the example class shown above, we get this output:

$ npx ts-node lib/hybrid/second.ts 
Decorator called on property [object Object] prop1
Decorator called on property [object Object] prop2
Decorator called on parameter [object Object] method 1
Decorator called on parameter [object Object] method 0
Decorator called on method [object Object] method {
  value: [Function: method],
  writable: true,
  enumerable: false,
  configurable: true
}
Decorator called on accessor [object Object] meaning {
  get: [Function: get meaning],
  set: [Function: set meaning],
  enumerable: false,
  configurable: true
}
Decorator called on class [class HybridDecorated]

As you can see, everything is correctly identified. The order in which the messages are printed happens to match the evaluation order discussed in Deep introduction to using and implementing TypeScript decorators

Summary

While reviewing different packages of TypeScript decorators, it was seen that the same decorator name is often used on multiple object types. To do that, the decorator must be inspecting its arguments to determine which context in which it is being used.

These functions will enable you to do the same in your decorator functions.

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)