It's 3pm in the workday, I'm a bit tired, and suddenly my co-worker submitted a PR that involved a completely custom TypeScript decorator! Oh no! Those things I've seen only in the Nest.js framework that I simply "use and they just work"! Now I have to understand what's happening here and give meaningful feedback on the PR. After checking the TypeScript docs on them and reading through the examples, I understood enough to give a "lgtm!" but overall not satisfied with how I understood them at the time (turns out thankfully they aren't as hard to understand as I originally thought, even though they often use the scary .apply
and this
keywords in what at first look like hard to understand ways 😅).
Now, at this point in my career I felt very comfortable with backend code in several languages, one of my favorites being TypeScript. Not too long ago I stumbled onto the Nest.js framework, upon which soon after I was introduced to the concept of decorators. They read well, but figuring out how they worked under the hood was a different ball game. As it turns out, while Nest.js decorators are nice to use because of their readability and their power, they actually somewhat steer you away from how TypeScript decorators work "under the hood". I for one, fell into this Nest.js-specific understanding of decorators, and as a result for over a year I was only really able to leverage the power of TypeScript decorators within Nest.js - which is actually pretty limiting in some ways, the PR situation I was in above notwithstanding.
In short, TypeScript decorators are a way to "hook" into methods of a class to change behavior of that method behind the scenes. They can also change the behavior of an entire class, or a parameter of a class method. They cannot be used outside of javascript classes at this time. Nest.js decorators on the other hand, rely more on reflection, nest.js custom request/response pipelines, and app interceptors to set metadata and change behavior via configuration of interceptors. In my opinion while they lean on TypeScript decorators, they are very much framework specific and in some ways actually more complicated than raw TypeScript decorators. For what it's worth you can do most of what you do in Nest.js decorators, however the patterns they open up typically also correspond with a pipeline type feature of Nest, specifically interceptors, guards, and controller methods. I wanted more, I wanted to be able to use the decorator on any method.
Starting with a small example - TypeScript decorators are actually quite simple:
function AddLogDecorator(target: any, propertyName: string, descriptor: PropertyDescriptor) {
const decoratedMethod = descriptor.value;
descriptor.value = function() {
console.log('add logging');
return decoratedMethod.apply(this, arguments);
}
}
class TestDecorator {
@AddLogDecorator
runTest() {
console.log('my decorator log should show before this message');
}
}
const instance = new TestDecorator();
instance.runTest();
Run this example yourself here!
You need a basic function that takes three arguments:
-
target
- the class that contains your decorator -
propertyName
- the method name you're decorating as a string -
descriptor
- the reference to the class method you're decorating - in this case therunTest
method.
In this example, instance.runTest()
is running my anonymous function descriptor.value = function() {
that I assigned - all I did was add a console.log
but even at this level you can already see the potential - you can do anything you want before or after the decorated function runs!
Fast forward a few months and I'm still working in the same codebase. It's grown quite large, and some of our repeat use cases have an amount of boilerplate associated with them that would make anyone tired! Caching is a big one. We all cache things, and when possible, it shouldn't be complicated. For us, we initially wrote our caching utility class so that it could be leveraged with dependency injection and very strong typing. Something like this:
import { MyCachingClass } from './my-caching-class';
export class ClassWithCachingUseCase {
constructor(private caching: MyCachingClass, private networking: NetworkingLayer) {}
public async getThingsThatMightBeCached(key: string) {
if (caching.isCached(key)) {
return caching.getCached(key);
}
const freshData = await networking.getActualData(key);
const ONE_HOUR_IN_SECONDS = 60 * 60;
caching.setCached(key, freshData, ONE_HOUR_IN_SECONDS);
return freshData;
}
}
We've all seen this kind of boilerplate. It seems innocuous at first, however over time, it grows like barnacles all over the code base. An extra unit test per class, an extra side effect to test in e2e cases, extra performance testing considerations, etc. etc.
I had a thought the other day - wouldn't it be nice if I could just write something like this instead?
import { CacheResponse } from './my-caching-class';
export class ClassWithCachingUseCase {
private static ONE_HOUR_IN_SECONDS = 60 * 60;
constructor(private networking: NetworkingLayer) {}
@CacheResponse(ONE_HOUR_IN_SECONDS)
public async getThingsThatMightBeCached(key: string) {
return networking.getActualData(key);
}
}
Anywhere you need it, it just works!
Sure enough, after digging through the TypeScript Docs plus some creative searching on google and stackoverflow, I found a combination of "tricks" that could get me what I needed, without being overly clever. The following is a modified example straight from the TypeScript documentation decorator examples to demonstrate the caching use case:
class ClassWithCachingExample {
responseCount = 0;
static CACHE_TIME_SECONDS = 60 * 60;
@CacheResponse(ClassWithCachingExample.CACHE_TIME_SECONDS)
async doStuff() {
return new Promise(resolve => {
// increment response count to show initial call is not cached
this.responseCount += 1;
// arbitrary 5 second delay to show that after the first call, the rest will be cached
setTimeout(() => resolve(this.responseCount), 5000);
});
}
}
I find it best to dive into decorators from the use case perspective - here is our class that will leverage the power of our caching decorator!'
Simple enough right? We have a class with a method on it that takes 5 seconds to execute. We want to cache that method call for 1 hour, so we add our cache decorator. The first call to that method should still take 5 seconds, but after that, every additional call for the next hour should only take the time required to pull the cached value from our cache!
// over simplified cache for demo purposes
let cacheObject: any = null;
function CacheResponse(timeInSeconds = 100) {
// notice this time we return a function signature - that's because
// we want to allow decoration with an input - ie @CacheResponse(timeInSeconds)
return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
const decoratedMethod = descriptor.value;
// dynamically generate a cache key with the class name + the decorated method name - should always be unique
const cacheKey = `${target.constructor.name}#${propertyName}`
// this can be set as async as long as it's decorated on an async function
descriptor.value = async function () {
console.log('calling decorated method');
if (cacheObject) {
console.log('cache hit - returning cache object');
return cacheObject;
}
console.log('cache miss - calling actual method and caching for next time');
// the "this" argument is correct for current scope because this is an anonymous function keyword, _not_ an arrow function
// arguments will be all arguments
cacheObject = await decoratedMethod.apply(this, arguments);
return cacheObject;
};
}
}
// our class from earlier
class ClassWithCachingExample {
responseCount = 0;
static CACHE_TIME_SECONDS = 60 * 60;
@CacheResponse(ClassWithCachingExample.CACHE_TIME_SECONDS)
async doStuff() {
return new Promise(resolve => {
// increment response count to show initial call is not cached
this.responseCount += 1;
// arbitrary 5 second delay to show that after the first call, the rest will be cached
setTimeout(() => resolve(this.responseCount), 5000);
});
}
}
// our running code to see our class + decorator in action!
const instance = new ClassWithCachingExample();
(async () => {
console.log('first call');
console.log(await instance.doStuff()); // 1 after a 5 second delay
console.log('the rest of the calls');
console.log(await instance.doStuff()); // 1 after no delay other than "awaited promise"
console.log(await instance.doStuff()); // 1 after no delay other than "awaited promise"
console.log(await instance.doStuff()); // 1 after no delay other than "awaited promise"
console.log(await instance.doStuff()); // 1 after no delay other than "awaited promise"
console.log(await instance.doStuff()); // 1 after no delay other than "awaited promise"
})();
I tried to comment the example code as best I could to make it understandable as you read through it, however if you want my "aha" moments from this use case, here they are:
- The example decorators in the TypeScript docs expect you to know a lot up front - it took me a surprising amount of time to figure out that I could re-assign the decorated method with an
async function
and "hook" into it - in my use case - implement some basic caching logic. - I had to re-educate myself on what .apply and arguments mean in vanilla javascript. They are powerful techniques, but are arguably less popular techniques in the strongly typed world of TypeScript - use them responsibly.
- I still don't know how to give my cache decorator access to my Nest.js dependency injection container in a useful way - for example - I want my custom logger class injected into my decorator so that I can log cache hits / misses - this is not feasible without a true external container like
awilix
or some other package.
I hoped you enjoyed reading this article and that perhaps, TypeScript decorators are a little less confusing for you now. This use case definitely helped me understand them. If you enjoyed this article, share it on your favorite social media site!
Top comments (0)