TypeScript Decorator: Types, Principles and Use Cases
Using Angular, I was intrigued by its decorators, those weird little constructs that are placed right before class definitions, such as @Component({ ...]})
in the Angular framework or @Get()
in nest.js. Digging into it further opened up a whole new dimension of how TypeScript code can be written and functionality abstracted, which will be the topic of this post.
Motivation
In some projects I worked on, I found deeply nested inheritance chains, sometimes 6 or 7 levels deep, with each layer adding another piece of the functionality. The result was a large abstract class at the top of the hierarchy which contained a lot of methods that solved many unrelated problems. The functionality provided by that mega-class was not always fully used and it was very hard to trace executions and find bugs through all the layers — especially for new developers.
I was yearning for Scala Traits, which are small pieces of code containing well-defined and self-contained functionality. The nice feature about Traits is that while you can’t instantiate them by themselves, you can combine multiple traits into your classes to provide a certain set of functionality. The end result is essentially a mechanism providing something akin to multiple inheritance — little pieces of self-contained functionality are combined to support new functionality.
TypeScript Mixins and Decorators
TypeScript already offers a suggestion for Mixins, which is based on a function which copies the respective methods and properties onto the constructor of the target class one by one:
function applyMixins(target: any, mixinList: any[]) {
baseCtors.forEach(mixin => {
Object.getOwnPropertyNames(mixin.prototype).forEach(name => {
Object.defineProperty(
target.prototype,
name,
Object.getOwnPropertyDescriptor(mixin.prototype, name)
);
});
});
}
While this is a straightforward solution, the self-documenting aspect falls short in this approach, as this function probably is defined in another file for reuse. Thus, when looking at the source code of a class, there is another import with functionality that has nothing to do with the business problem to be solved and is pure mechanics.
In addition, this function would likely be called after the class definition in a file, so it is easily overlooked at the end of a file. Alternatively, I could imagine creating a “Mixin party”, where there is one file that imports the applyMixins()
function and augments all the required classes with their needed mixins — it just seems clunky.
Another, clearer way to achieve the same goal is through Decorators, which
- clearly communicate a class’ purpose at the top of its definition and
- augment the functionality to achieve a certain outcome.
At their core, Decorators essentially are mixing in functionality into a prototype, much like Mixins
. The difference is that this mixing is obvious through a language construct at the beginning of the class. I first came across decorators as part of Angular, but they are more generally part of Typescript and are even in consideration (Stage 2) for an upcoming version of JavaScript.
Using a class decorator called Frozen
would look like this (from the proposal):
@Frozen
class MyClass {
method() { }
}
There are several different types of decorators, which can be used on
- a class to augment or change properties and methods.
- methods to augment or override functionality.
- properties to check on or enforce certain properties on a class property. I won’t go into detail here, but there is an example implementing a decorator to check for null values.
- parameters. This is used, for example, in Angular with the
@Optional
decorator which permits optional parameters to a class’ constructor.
Implementing Decorators
Class Decorators
In their simplest form, class decorators can be used to introduce new properties into a class. As an example, here is the implementation of a RandomNumber
decorator, which introduces a new property on a class and prefills it with a random number:
export function RandomNumber(max: number): ClassDecorator {
return function(constructor: any) {
const randomNumber = Math.round(Math.random() * max);
constructor.prototype.randomNumber = randomNumber;
};
}
Essentially, it is a curried function which accepts as its first parameter the upper limit for a random number to be generated on line 1. It returns the actual decorator code, which
- gets passed a reference to the constructor of the decorated class on line 2
- generates a random number
- augments the constructor of the decorated class with a new property
randomNumber
, which exposes the random number to the class instance.
When the decorated class is now instantiated, it possesses a new property which contains the randomly generated number. Here is an example of the decorator’s use in an Angular component:
@Component({
selector: "component",
template: "<p>The number is: {{ randomNumber || 'NaN' }}</p>"
})
@RandomNumber(400)
export class SomeComponent {
title = 'ts-decorators';
}
This is 80% of the code we need to write. There is a problem with the typing of that code, though. TypeScript does not know about the randomNumber
property on your class and your IDE, well mine anyway, highlights the property in your code as undefined. This is remedied by introducing an interface that defines the property of the @RandomNumber
decorator:
export interface RandomNumberInterface {
randomNumber: number;
}
which your component then of course needs to implement:
@RandomNumber(400)
export class AppComponent implements RandomNumberInterface { ... }
This is admittedly a very, very simple problem to solve, for which it is barely worth it to write a decorator. But the benefit of this kind of implementation should be clear: With the decorator, there exists now a mechanism which is some self-contained functionality that can be applied to any TypeScript class.
It thus presents an abstraction of a common functionality — dare I say concern? — that can be used independently of other factors and applied to any class. This decorator would work just as well on an Angular Service as a Component or other building block.
This, of course, is a very simple example of a class decorator. In a later post, I will look into how we can encapsulate functionality into decorators to get to the original goal of implementing self-contained morsels of functionality à la Scala Traits.
Method Decorators
In the previous section, I described how to create a simple decorator to introduce new functionality into a class by creating and populating a new property. Decorators can also be used on methods and modify or overwrite their behavior.
Depending on the project and the developers, I have seen more and more console.log()
calls in production code over time. One could write a decorator, which abstracts out logging and in addition, checks the environment. In production environments, the logging would be suppressed, while in development, the logs could still show. (N.B.: I don’t know if the following is encouraging bad behavior or simply makes sure no logs are shown in production).
Here is an example on how such a decorator could be used:
class SomeComponent {
@LogResult()
someMethod() {
}
}
This again is readble and easily digestible code. The functionality of someMethod()
is wrapped by the decorator, which through its name provides easily unterstood information on what its functionality is. What’s more, the entire functionality and logic surrounding the logging is abstracted into its own, named, stand-alone and reusable construct.
This implementation further makes sure that the logging functionality is the same everywhere. The decorator code looks like this:
export function LogResult(type: string = 'log'): MethodDecorator {
return function(target: any, key: string, descriptor: PropertyDescriptor) {
const origMethod = descriptor.value;
descriptor.value = function(...args) {
if (environment.permitLogging) {
console[type](`${ propertyKey }() called with: `, args);
}
const result = origMethod.apply(target, args);
if (environment.permitLogging) {
console[type](`${ propertyKey }() result: `, result);
}
return result;
};
};
}
It’s again a curried function, which takes the desired console method as its first parameter (and defaults to “log”) and returns a function that implements the decorator’s functionality. This inner function accepts three parameters
Name | Type | Meaning |
---|---|---|
target | any |
A reference to the prototype of the class the decorated method belongs to (for instance members) |
key | string |
The method name |
descriptor | PropertyDescriptor |
Describes the method properties such as whether it is writable . The value property contains a reference to the decorated method implementation |
The functionality of the @LogResult
decorator is told quickly:
- The decorator accepts a parameter for which type of console output should be generated. It defaults to
console.log
but can be overwritten with the parameter. - A reference to the original method is stored in line 5, then the original method in
descriptor.value
is overwritten starting in line 6 - Within the new method, we check the environment whether logging should be permitted, and if so, we show a message that the method was called and with which parameters.
- After the console output, the decorator proceeds to call the original Method and stores its result in a constant
- The return value from the decorated method gets put on the console and then returned.
This decorator therefore provides a standard way of logging the input/output of a method with very little effort, simply through addition of a decorator — but only does so if not in production environments. While this is stand-alone functionality, it gets interesting when combining class and method decorators, as I will describe in a forthcoming post.
The Way Forward
In this post, I have given a brief overview of decorators and their cousins: TypeScript Mixins. The examples given here for class and method decorators are very basic but ilustrate the principles of their implementation.
In a later post, I will develop the idea of a more complex @FormSync
decorator which will permit multiple forms on the same page to exchange information about their status. The example will also demonstrate how method and class decorators can work together to achieve more complex functionality.