DEV Community

w
w

Posted on • Originally published at jaywolfe.dev on

I Thought TypeScript Decorators Were Hard - Now I Use Them To Cache Async Data

I Thought TypeScript Decorators Were Hard - Now I Use Them To Cache Async Data

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();
Enter fullscreen mode Exit fullscreen mode

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 the runTest 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

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"
})();
Enter fullscreen mode Exit fullscreen mode

If you'd like to run this example in a sandbox, here is a pre-baked url for the TypeScript Playground where you can run this code in the browser.

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)