Caching Observables/Promises

Written by msarica | Published 2020/01/06
Tech Story Tags: javascript | typescript | programming | software-development | observable | promise | generators-and-decorators | interface

TLDR Typescript decorator is a special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the form @expression, where expression must evaluate to a function that will be called at runtime with information about the decorated declaration. We can create a custom decorator and drop it on a method and be done with it. We check if value is defined, if it is, return the value. If not, call the original function to get the value and return it.via the TL;DR App

Sometimes, we may just want to cache a method return value for sometime. Typescript decorators come really handy. We can create a custom decorator and drop it on a method and be done with it.
  @Cache({
    duration: 10000
  })
  method2(){
    return new Promise(resolve=> {
      setTimeout(()=> resolve(Math.random()), 1000);
    });
  }
"A Decorator is a special kind of declaration that can be attached to a class declarationmethodaccessorproperty, or parameter. Decorators use the form @expression, where expression must evaluate to a function that will be called at runtime with information about the decorated declaration."
Let's start simple!
  @Cache()
  method1(){
    return Math.random();
  }
export function Cache(){
    let originalFunc: Function;

    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        originalFunc = descriptor.value;

        descriptor.value = function() {
          console.log('time: ', new Date());
          console.log('why is this doing??')
        };
    }; 
We took the
descriptor.value
which contains the function/method that has the decorator. So this is our original function. We put it in a variable aka
originalFunc
(what an original name huh!) and provide a new function! So when called instead of our original function this function will be called instead!
Of course, we can also call the original function here.
export function Cache(){
    let originalFunc: Function;

    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        originalFunc = descriptor.value;

        descriptor.value = function() {
          console.log('time: ', new Date());
          return originalFunc();
        };
    }; 
}
So only difference now is this decorator shows the timestamp before running the original function (Original function is just returning a random number).
Let's actually cache the value!
export function Cache(){
    let originalFunc: Function;
    let value: any;

    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        originalFunc = descriptor.value;

        descriptor.value = function() {

          if(value){
            console.log('from cache')
            return value;
          }
          
          console.log('caching')
          value = originalFunc();
          return value;
        };
    }; 
}
We added a new variable called
value
. We check if value is defined, if it is, return the value. If not, call the original function to get the value and return it.
Now, we have a problem this will cache indefinitely. We may not want to cache it forever. Instead it will be better if we can decide for how long we want it to cache.
Let's add an interface for the parameters. For now, we have only duration but we can add more if we want to. We have also created an object with default values so that if not passed in, we can give some default values.
export interface CacheOptions { // we may add additional parameters here
  duration?: number
}

export function Cache(params: CacheOptions = {}){
    const defaultValues: Partial<CacheOptions> = {
      duration: 3000,
    }

    params = {
      ...defaultValues,
      ...params
    };
...
We now need to calculate due time.
...
    let originalFunc: Function;
    let value: any;
    let cacheUntil: Date;

    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        originalFunc = descriptor.value;

        descriptor.value = function() {

          const now = new Date();
          if (value && cacheUntil && cacheUntil > now) {
            console.log("from cache");
            return value;
          } 
          
          console.log('caching')
          value = originalFunc();
          cacheUntil = new Date(now.getTime() + params.duration);
          return value;
        };
    }; 
}
In addition to checking value, we also are checking if cacheUntil is ahead of now. If so, returning the cached value. Otherwise, after getting the new value, we calculate the cacheUntil value.
As you can see, now it's not caching indefinitely. It expires every 3 seconds.

How about observables/promises?

Before adding more code, I will put the caching logic into an inline function so that we can call in multiple places without leaving the function scope.
...
    const cacheValue = (val, now)=> {
      console.log("caching ");
      cacheUntil = new Date(now.getTime() + params.duration);
      value = val;
    };

    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        originalFunc = descriptor.value;

        descriptor.value = function() {

          const now = new Date();
          if (value && cacheUntil && cacheUntil > now) {
            console.log("from cache");
            return value;
          } 
          
          console.log('caching')
          value = originalFunc();
          cacheValue(value, now);
          return value;
        };
    }; 
...
When we called the original function, the return type could be a value (we assumed that it will be a value so far), it can also be promise or an observable.
...
          const result = originalFunc();
          
          if(result instanceof Observable){
            funcType = 'observable';
            return result.pipe(
              tap(val => {
                cacheValue(val, now);
              }));
          } else if( result instanceof Promise){
            funcType = 'promise';
            return result
                  .then(value=> {
                    cacheValue(value, now);
                    return value;
                  });
          } else {
            funcType = 'value';
            cacheValue(result, now);
            return result; 
          }
...
We check the instance of the result to determine the type of it. I added a new variable called funcType as we will need it, when we are returning the cached value. Based on each type, we cached the value and return the value in a consistent way.
And for returning the cached value:
...
          if (value && cacheUntil && cacheUntil > now) {
            console.log("from cache");
            switch (funcType){
              case "observable": return of(value);
              case "promise": return Promise.resolve(value);
              default: return value;
            }
          } 
...
Probably, in a real scenario
originalFunc()
will throw an error related to
this
keyword. Let's replace it as
originalFunc.apply(this);
. If our methods have parameters, we can use
originalFunc.apply(this, args);
instead.

Finally:


export interface CacheOptions { // we may add additional parameters here
  duration?: number
}

export function Cache(params: CacheOptions = {}){
    const defaultValues: Partial<CacheOptions> = {
      duration: 3000,
    }

    params = {
      ...defaultValues,
      ...params
    };

    let originalFunc: Function;
    let value: any;
    let cacheUntil: Date;

    let funcType: string;

    const cacheValue = (val, now)=> {
      console.log("caching ");
      cacheUntil = new Date(now.getTime() + params.duration);
      value = val;
    };

    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        originalFunc = descriptor.value;

        descriptor.value = function() {

          const now = new Date();
          if (value && cacheUntil && cacheUntil > now) {
            console.log("from cache");
            switch (funcType){
              case "observable": return of(value);
              case "promise": return Promise.resolve(value);
              default: return value;
            }
          } 
          
          const result = originalFunc.apply(this);
          
          if(result instanceof Observable){
            funcType = 'observable';
            return result.pipe(
              tap(val => {
                cacheValue(val, now);
              }));
          } else if( result instanceof Promise){
            funcType = 'promise';
            return result
                  .then(value=> {
                    cacheValue(value, now);
                    return value;
                  });
          } else {
            funcType = 'value';
            cacheValue(result, now);
            return result; 
          }
        };
    }; 
}
export class AppComponent  {

  callMethod1(){
   console.log( this.method1());
  }

  callMethod2(){
    this.method2().then(console.log);
  }

  callMethod3(){
    this.method3().subscribe(console.log)
  }

  @Cache()
  method1(){
    return Math.random();
  }

  @Cache({
    duration: 10000
  })
  method2(){
    return new Promise(resolve=> {
      setTimeout(()=> resolve(Math.random()), 1000);
    });
  }

  @Cache()
  method3(){
    return of(Math.random())
    .pipe(debounceTime(1000))
  }
}

Caution

When we add parameters to our methods, even though the arguments change, the cached value will not change. We may need to add a hashing method to differentiate the different calls so that we can skip caching.
You can see the demo on here.

Written by msarica | msarica.com
Published by HackerNoon on 2020/01/06