Deep introduction to class decorators in TypeScript

; Date: Thu Feb 10 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. Class decorators are applied to class definitions in TypeScript, and can observe, modify, or replace the class definition.

This article takes a deep dive into defining and using TypeScript class decorators. To use decorators, they must be enabled in TypeScript, so be sure to review the introduction to decorators article in this series. Primarily class decorators are used to add metadata to the class, which will be used by other decorators. But, class decorators can also return a new constructor to override or replace the existing constructor, or class, adding new methods or other behavior.

In practice, they look like this:

@Decorator( ?? optional arguments)
class DecoratedClass {
    // properties, or methods
}

That is, you immediately precede the class keyword with one or more decorators. With some decorators the parameters configure its behavior. Other decorators do not require parameters, which will be explained in the documentation.

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.

Class Decorator Functions in TypeScript

The decorator function takes one argument:

  1. The constructor function of a class

This defines a required signature for property decorator functions.

Let's try a simple example of a class decorator, which simply prints the data it is given.

function logConstructor(constructor: Function) {
    const ret = {
        constructor,
        extensible: Object.isExtensible(constructor),
        frozen: Object.isFrozen(constructor),
        sealed: Object.isSealed(constructor),
        values: Object.values(constructor),
        properties: Object.getOwnPropertyDescriptors(constructor),
        members: {}
    };
    for (const key of Object.getOwnPropertyNames(constructor.prototype)) {
        ret.members[key] = constructor.prototype[key];
    }

    console.log(`ClassDecoratorExample `, ret);
}

@logConstructor
class ClassDecoratorExample {
    constructor(x: number, y: number) {
        console.log(`ClassDecoratorExample(${x}, ${y})`);
    }
    method() {
        console.log(`method called`);
    }
}

new ClassDecoratorExample(3, 4).method()

The logConstructor is the decorator function for the @logConstructor decorator, and it implements the prescribed method signature for class decorators. This decorator does not use any parameters, and due to its implementation no parentheses are required.

It simply prints out available information about the constructor function. For most we used methods in the Object class to query data about the class being decorated. In a couple cases the query was against constructor.prototype, because that object contains implementation details of methods attached to the class.

The (www.npmjs.com) decorator-inspectors package in the repository contains a more comprehensive decorator, LogClassInspector, with a similar purpose.

When run, we get this output:

$ npx ts-node lib/classes/first.ts 
ClassDecoratorExample  {
  constructor: [class ClassDecoratorExample],
  extensible: false,
  frozen: false,
  sealed: false,
  values: [],
  properties: {
    length: {
      value: 2,
      writable: false,
      enumerable: false,
      configurable: false
    },
    name: {
      value: 'ClassDecoratorExample',
      writable: false,
      enumerable: false,
      configurable: false
    },
    prototype: {
      value: {},
      writable: false,
      enumerable: false,
      configurable: false
    }
  },
  members: {
    constructor: [class ClassDecoratorExample],
    method: [Function: method]
  }
}
ClassDecoratorExample(3, 4)
method called

The values in properties are PropertyDescriptor objects which we'll see again and again. Most of us do not need to delve this deeply behind the scenes of JavaScript. But, to implement decorators we will need to know a few things.

For example, did you know about the sealed or frozen settings on Objects? Now that I know about them, it's sure looks useful to be able to turn on either of those settings. This decorator makes it easy:

function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
}

If you attach this to a class definition, that class will be sealed.

To use it, add it to the script:

@logConstructor
@sealed
class ClassDecoratorExample { ... }

We can easily add multiple decorators to any decoratable thing. There is an execution order for multiple decorators, in which they execute from the bottom upwards. Hence, the output will now say sealed: true because @sealed will have executed first. If you place @sealed above logConstructor it will still say sealed: false because @sealed will have executed second.

Implementing class decorators that accept parameters

Decorators can also take arguments. As we said in the introduction, this requires following a different pattern called the decorator factory.

Here is a simple class decorator example that not only shows how to pass in parameters, but helps us understand the order of execution when using multiple decorators.

function withParam(path: string) {
    console.log(`outer withParam ${path}`);
    return (target: Function) => {
        console.log(`inner withParam ${path}`);
    };
}

@withParam('first')
@withParam('middle')
@withParam('last')
class ExampleClass {
}

The outer function, withParam, takes the argument list to be used with the decorator. The inner function is the decorator function, and is where the required signature is to be implemented. It is the inner function that will contain the actual decorator implementation.

What happens is withParam(parameter) is an expression which returns a function with the correct signature to be a class decorator. That makes the inner function the decorator function, and the outer function is a factory that generates that function.

In this example we've attached withParam three times so we can learn a bit more about the execution order.

$ npx ts-node lib/classes/constructors.ts  
outer withParam first
outer withParam middle
outer withParam last
inner withParam last
inner withParam middle
inner withParam first

We discussed this in detail in the introduction. Remember that the factory functions execute from top to bottom, and after that the decorator functions execute from bottom to top.

Class decorator which registers with a framework

Let's examine one possible practical use for a class decorator. Namely, a framework might keep lists of classes of certain kinds. For this example we'll mimic a web app framework where certain classes hold URL routing functions. Each router class handles routes for a certain URL prefix, along with specific configuration for that route.

const registeredClasses = [];

function Router(path: string, options ?: object) {
    return (constructor: Function) => {
        registeredClasses.push({
            constructor, path, options
        });
    };
}

@Router('/')
class HomePageRouter {
    // routing functions
}

@Router('/blog', {
    rss: '/blog/rss.xml'
})
class BlogRouter {
    // routing functions
}

console.log(registeredClasses);

Router is a factory function producing a class decorator that adds classes to the registeredClasses array. The function takes two options, where path is the URL path prefix, and options is an optional configuraton object.

Because class decorators run last, there's an option for the class decorator do something in regard to any methods or properties contained within the class. Also, the more common approach to storing data is not an array like this, but to use the Reflection Metadata API, which we talk about later.

The output from running this is:

$ npx ts-node lib/classes/register.ts 
[
  {
    constructor: [class HomePageRouter],
    path: '/',
    options: undefined
  },
  {
    constructor: [class BlogRouter],
    path: '/blog',
    options: { rss: '/blog/rss.xml' }
  }
]

It is up to the hypothetical framework to do something with this data. As we saw earlier, there is quite a lot of additional data that is available by starting with the constructor object.

Modifying a class using a Class Decorator in TypeScript

This simple examples were interesting, but let's try doing something interesting. It's promised that we can modify or replace the class definition. This will require a bit of wizardry, so let's take a look.

Before we start, take a half a step back and think about this - The class decorator function receives the class object. It does not receive the instance that is being created. We can add properties using Object.defineProperty(target, ...), but that property is added to the class, and not to any instance which is generated.

To grab onto a random concept, consider an application for recording events during the day of an employee. The employee first clocks in, then after their shift they clock out. Let's create two classes to record these events, and use a decorator to automatically add a time stamp and unique identifier.

import { v4 as uuidv4 } from 'uuid';

function TimeStamp<T extends { new(...args: any[]): {}}>(target: T) {
    return class extends target {
        uuid = uuidv4();
        created = new Date().toLocaleString("en-US");

        hello(msg: string) { console.log(`Extended ${msg}`); }
    }
}

@TimeStamp
class ClockIn {
    // methods and properties
}

@TimeStamp
class ClockOut {
    // methods and properties
}

The TimeStamp decorator has a curious declaration, but this does work, it comes out of the official TypeScript documentation, and does match the required signature for class decorator functions. It appears the extends clause has something to do with extending a generic class, and therefore T in this case matches any class definition.

The part with <T extends { new(...args: any[]): {}}> is a Generic, where T is defined as something which extends any class. This syntax comes directly from the TypeScript documentation, and is perhaps a more precise way of declaring a class decorator.

The part with return class extends target is a class operation, and will create a new class that extends the class being decorated. To this new class we've added two fields, and a function. The uuid field is meant to be a unique identifier, and the created field is a time stamp.

In the TypeScript documentation, this technique is described as overriding the constructor.

To test this, use the following:

const ci = new ClockIn();
const ci2 = new ClockIn();
const co = new ClockOut();

console.log(ci);
console.log(ci2);
console.log(co);

console.log(ci.hasOwnProperty('uuid'));
console.log(ci['uuid']);
console.log((<any>ci).uuid);
ci['hello']('World');
(<any>ci).hello('World #2');

We're generating two instances of ClockIn to verify that each instance gets a unique identifier. We then print out some pieces of data.

When run it looks like this:

$ npx ts-node lib/classes/timestamp.ts 
ClockIn {
  uuid: 'bc3e6f35-c85d-491d-82c0-c906deacc774',
  created: '2/10/2022, 6:11:53 PM'
}
ClockIn {
  uuid: '13eb2df6-b9e7-4735-9130-a7ee13182d33',
  created: '2/10/2022, 6:11:53 PM'
}
ClockOut {
  uuid: '31ce8e88-90e0-40d7-9eb2-cbba5c879b5d',
  created: '2/10/2022, 6:11:53 PM'
}
true
bc3e6f35-c85d-491d-82c0-c906deacc774
bc3e6f35-c85d-491d-82c0-c906deacc774
Extended World
Extended World #2

Our objects do have the added fields, and the two ClockIn instances have different identifiers.

Where the code became interesting was trying to directly access a field, such as ci.uuid. The compiler gives us an error saying the named field does not exist on the type, presumably because the ClockIn class does not have a field named uuid. But we learn by calling hasOwnProperty that the added properties do exist, and we can access the properties using ci['uuid'] or by casting ci to any as in (<any>ci).uuid.

While we managed to modify the class using a decorator, the result is on the tricky side. Accessing the added properties is unnatural, in this case, making it unattractive to use.

The TypeScript documentation demonstrates the same issue. Their example shows adding a field, reportingURL, via the same mechanism. But an attempt to access that property throws an error, Property 'reportingURL' does not exist on type 'BugReport'. It's explained that because the TypeScript type has not changed, the type system does not know about the added properties. As we've demonstrated, those properties are there, and the properties are available by jumping through a hoop.

But, what happens if we instead override a field defined in the class. Consider this:

function Override<T extends { new(...args: any[]): {} }>(target: T) {
    return class extends target {
        area(w: number, h: number) {
            return {
                w, h, area: w * h
            };
        }
    }
}

@Override
class Overridden {

    area(w: number, h: number) {
        return w * h;
    }
}

console.log(new Overridden().area(5, 6));
console.log(new Overridden().area(6, 7));

We have a class, Overridden, and a decorator named Override. The class has a function named area which simply returns the obvious value, the result of multiplying width and height. The overridden version returns an anonymous object containing.

Running the script we get:

$ npx ts-node lib/classes/override.ts 
{ w: 5, h: 6, area: 30 }
{ w: 6, h: 7, area: 42 }

Clearly, this did successfully replace the original function with the new one. Further, we did not have to jump through any hoops to access the overriding functionality.

What happens if you override a class constructor in the anonymous subclass inside a class decorator?

For example, there are many decorator libraries in the npm/yarn repository focused on logging object creation, method calls, and property accesses. To learn a little how that works, let's look at one way to print a log message when an instance of a class is created.

import * as util from 'util';

function LogClassCreate<T extends { new(...args: any[]): {}}>(target: T) {
    return class extends target {
        constructor(...args: any[]) {
            super(...args);
            console.log(`Create ${util.inspect(target)} with args=`, args);
        }
    }
}

@LogClassCreate
class Rectangle {
    width: number;
    height: number;

    constructor(width: number, height: number) {
        this.height = height;
        this.width = width;
    }

    area() { return this.width * this.height; }
}

@LogClassCreate
class Circle {
    diameter: number;
    constructor(diameter: number) {
        this.diameter = diameter;
    }

    area() { return ((this.diameter / 2) ** 2) * (Math.PI); }
}

const rect1 = new Rectangle(3, 5);
console.log(`area rect1 ${rect1.area()}`);

const rect2 = new Rectangle(5, 8);
console.log(`area rect2 ${rect2.area()}`);

const rect3 = new Rectangle(8, 13);
console.log(`area rect3 ${rect3.area()}`);

const circ1 = new Circle(20);
console.log(`area circ1 ${circ1.area()}`);

We have two simple classes, Rectangle and Circle. The LogClassCreate decorator extends the class to give it a custom constructor method. Because this method uses ...any it can be used with any constructor with any number of arguments.

While the class decorator is invoked when the program launches, the constructor for the anonymous subclass is executed when class instances are created.

To verify that's accurate, run the script and study the output:

$ npx ts-node lib/classes/override2.ts 
Create [class Rectangle] with args= [ 3, 5 ]
area rect1 15
Create [class Rectangle] with args= [ 5, 8 ]
area rect2 40
Create [class Rectangle] with args= [ 8, 13 ]
area rect3 104
Create [class Circle] with args= [ 20 ]
area circ1 314.1592653589793

The messages starting with Create come from inside the constructor in the anonymous subclass. Indeed, this is decorator code which is executing when instantiating a class instance.

Notice that it correctly records the parameters to the constructor, and that each object is correctly initialized, and produces the correct results. Well, the last one is correct within the limits of JavaScript mathematics, since Math.PI is a rough estimate.

Possible failure for Class Decorators

What happens if you leave off the required parameter from the class decorator function?

function Decorator() {
    console.log('In Decorator');
}

@Decorator
class FooClass {
    foo: string;
}

In this case, this compile-time error is thrown:

error TS1329: 'Decorator' accepts too few arguments to be used as a decorator here. Did you mean to call it first and write '@Decorator()'?

The issue in this case is that class decorators are, as said above, required to take one argument, which is the constructor for a class. We left off that required parameter, and the error messages kinda-sorta-maybe-possibly is telling us so.

Summary

We have learned quite a bit about class decorators. The decorator receives the class object, and from it we can access quite a lot of data.

The decorator function executes when the class object is created, rather than when class instances are constructed. It means to directly influence anything about generated instances, we must create an anonymous subclass.

Working with the anonymous subclass can be tricky. Accessing any added methods or properties requires jumping through hoops, where overridden methods or properties execute transparently.

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)