Building a Type Agnostic Cache Using Generics in TypeScript

Post Editor

When building a client application, some requests to the server are identical regardless of the specific view the user is in. A cache eliminates redundant HTTP calls and may also help reduce the number of potential future HTTP calls if it implements some additional logic.

8 min read
post

Building a Type Agnostic Cache Using Generics in TypeScript

When building a client application, some requests to the server are identical regardless of the specific view the user is in. A cache eliminates redundant HTTP calls and may also help reduce the number of potential future HTTP calls if it implements some additional logic.

post
post
8 min read

Introduction
Link to this section

When building a client application, some requests to the server are identical regardless of the specific view the user is in. A cache eliminates redundant HTTP calls and may also help reduce the number of potential future HTTP calls if it implements some additional logic for analysing the response received from the server. Because the implementation of the cache should be independent of the response format, we apply generics.

Caching can be implemented by using RxJS's shareReplay operator. Different consumers subscribe to the same ReplaySubject, and server responses are replayed to them instead of requesting the same information from the server repeatedly:

If your application needs to fetch some simple pieces of data, this approach is totally sufficient. But what if the data fetched from the server is more complex and might even have dependencies on other pieces of data? And wouldn't it be desirable to separate the logic needed for caching from the actual type of response you deal with? If this is the case, then this article may be of interest to you.

Caching using shareReplay
Link to this section

Upon a request, a new entry is created in the cache if it does not exist yet, otherwise it is directly returned from the cache. In the example shown below, we are applying RxJs's shareReplay operator to turn the source Observable returned by the method requestItemFromServer into a ReplaySubject that multiple consumers can then subscribe to, without re-executing the request to the server.

<>Copy
export abstract class GenericCache<T> { /** * Retrieves an item from the server. * * @param key the id of the item to be returned. */ protected abstract requestItemFromServer(key: string): Observable<T>; // key -> value private cache: { [key: string]: Observable<T> } = {}; /** * Gets a specific item from the cache. * If not cached yet, the information will be retrieved from the server. * * @param key the id of the item to be returned. */ getItem(key: string): Observable<T> { // If the key already exists, // return the associated Observable (ReplaySubject). if (this.cache[key] !== undefined) { return this.cache[key]; } // writes the new ReplaySubject to the cache this.cache[key] = this.requestItemFromServer(key, isDependency).pipe( take(1), // set buffer size to 1 // deactivate reference counting shareReplay({refCount: false, bufferSize: 1}) ); // return the new ReplaySubject return this.cache[key]; } }
Generic Cache with the shareReplay Operator

Applying the operator shareReplay is a compact form for performing multicasting using a ReplaySubject. This topic is covered extensively in the article Understanding RxJS Multicast Operators.

This approach also works if the cache gets several requests for the same element while the fetching of data is still in progress. Also late subscribers will get the ReplaySubject's latest (and only) value. Since we are dealing with HTTP requests, the buffer size is set to 1 because there won't be any other emission after completion. For the same reason, we do not need reference counting which would automatically unsubscribe from the source Observable and resubscribe to it, depending on the active subscriptions to the Subject.

As you can see, we cache an Observable of type T to make the logic generic. GenericCache is an abstract class that cannot be instantiated. For a specific type of response, a subclass of GenericCache can be made. This approach is covered in the section "Separating the Caching Logic from the Type of Response".

Resolving Dependencies
Link to this section

We have a use case where we request a project's data model. There is a base data model all project data models depend on, and project data models may refer to other project data models. In fact, also interdependencies of data models are allowed. (A refers to B and vice versa.)

Upon receipt of a data model via requestItemFromServer, we analyse its references to other data models. Given a data model, the method getDependenciesOfItem returns an array of keys identifying data models it depends on. For example, project data model A depends on the base data model and on data model B, whereas the base data model is self-contained and data model B depends on the base data model and contains no references to other project data models.

Project Data Model A with Dependencies

Since it is very probable that the consumer that requested data model A will also ask for both the base data model and data model B, we also request those elements and cache them.

<>Copy
/** * Given an item, determines its dependencies on other items (their ids). * * @param item the item whose dependencies have to be determined. */ protected abstract getDependenciesOfItem(item: T): string[]; /** * Requests dependencies of the item retrieved from the server. * * @param item item returned from the server to a request. */ private requestDependencies(item: T) { this.getDependenciesOfItem(item) .filter((depKey: string) => { // ignore dependencies already taken care of return !Object.keys(this.cache).includes(depKey); }) .forEach((depKey: string) => { // request each dependency from the cache // dependencies will be fetched asynchronously. this.getItem(depKey).subscribe(); }); } protected abstract requestItemFromServer(key: string): Observable<T>; getItem(key: string): Observable<T> { ... this.cache[key] = this.requestItemFromServer(key) .pipe( take(1), tap( (item: T) => this.requestDependencies(item) ), shareReplay({refCount: false, bufferSize: 1}) ); return this.cache[key]; }
Resolving Dependencies of Cached Items

To avoid unnecessary calls of getItem, we first check if the item already exists in the cache. (It is not relevant whether the data has already been fetched from the server or the request is still in progress.) If there is no entry yet for a key, the item is requested. Note that the calls are not synchronised. This is because interdependencies would lead to blocking calls.

Since the requested item's dependencies are already taken care of, it is likely that they are present in the cache once the client asks for them. Note that subscribe is called on getItem because otherwise the entry would just be created in the cache but not start fetching the data.

Everything contained in tap will only be done once regardless of how many times the related element is retrieved from the cache. This is because subscribers subscribe to a ReplaySubject returned by sharedReplay and not the underlying Observable, see docs.

Optimising Possible Future Requests
Link to this section

Another use case we have to cover are hierarchical lists. The server offers two methods to get information about a hierarchical list: one to get a single list node and one to get the list as a whole. The list is identified by its root node.

Hierarchical List

Now when the client asks for a single list node, the whole list can be retrieved as a dependency and all of its nodes are written to the cache. After that, any node from the list can be requested from the cache without performing additional HTTP requests to the server.

<>Copy
protected abstract getDependenciesOfItem(item: T): string[]; /** * Given an item, determines its key. * * @param item The item whose key has to be determined. */ protected abstract getKeyOfItem(item: T): string; /** * Retrieves an item from the server. * * @param key the id of the item to be returned. * @param isDependency true if the requested key is a dependency of another item. */ protected abstract requestItemFromServer(key: string, isDependency: boolean): Observable<T[]>; /** * Requests dependencies of the items retrieved from the server. * * @param items items returned from the server to a request. */ private requestDependencies(items: T[]) { ... // flag as dependency this.getItem(depKey, true).subscribe(); ... } /** * Handle additional items that were resolved with a request. * * @param items dependencies that have been retrieved. */ private saveAdditionalItems(items: T[]) { // Write all available items to the cache (only for non existing keys) // Analyze dependencies of available items. items.forEach( (item: T) => { // Get key of item const itemKey = this.getKeyOfItem(item); // Only write an additional item to the cache // if there is no entry for it yet if (this.cache[itemKey] === undefined) { this.cache[itemKey] = of(item); } } ); } getItem(key: string, isDependency = false): Observable<T> { ... this.cache[key] = this.requestItemFromServer(key, isDependency) .pipe( take(1), tap( (items: T[]) => { // save all additional items returned for this request this.saveAdditionalItems(items.slice(1)); // request dependencies of all items this.requestDependencies(items); } ), map((res: T[]) => res[0]), shareReplay({refCount: false, bufferSize: 1}) ); return this.cache[key]; }
Optimising Possible Future Requests

Note that requestItemFromServer now returns an array of T. This means that now it is possible to get all nodes of a list in one request. This works in three steps:

  1. The requested list node is retrieved from the server, requestItemFromServer returns an array of length one containing the list node.
  2. requestDependencies analyses the list node's dependencies and requests the list's root node to get the whole list. Note that  getItem's second argument isDependency is set to true and is passed to the method requestItemFromServer. With that information, requestItemFromServer returns an array containing all nodes of the list. (In fact, isDependency can be used to distinguish between the request of a single list node and the root node which represents the whole list.)  All the nodes except the root node (note slice) are written to the cache in saveAdditionalItems. By convention, the root node is the first element in the array.
  3. As a last step in the pipe before the shareReplay operator, the array of nodes is mapped to the first node (the requested list node in case of step 1 and the root node in case of step 2), that is then contained in the ReplaySubject.

Retrying Failed Requests
Link to this section

If a request to the servers fails, the subscriber will get notified in the error callback. In that case, the source Observable will be retried upon the next request for the same element.

Separating the Caching Logic from the Type of Response
Link to this section

The logic described above is implemented independently of a certain type of response from the server. However, it makes sense to assume it is an object type: abstract class GenericCache<T extends object>. Some of the methods in the class GenericCache are abstract and so is the class itself. This means in order to use GenericCache, an implementation has to be provided implementing all of its abstract methods:

  • protected abstract requestItemFromServer(key: string, isDependency: boolean): Observable<T[]>: fetches an item identified by the key from the server. This returns an array of length one containing the requested item or an array containing more items of the same type if the query can be optimised, the first element being the item identified by the key.
  • protected abstract getDependenciesOfItem(item: T): string[]: returns an array of ids (keys) the given item depends on.
  • protected abstract getKeyOfItem(item: T): string: returns the id (key) of the given item.

For DataModel, the GenericCache can now be implemented as follows:

<>Copy
export class DataModel { id: string; label: string; dependencies: string[]; // ids of data models this data model depends on ... } export class DataModelCache extends GenericCache<DataModel> { protected requestItemFromServer(key: string, isDependency: boolean): Observable<DataModel[]> { return ajax.get('https://www.mybackend.com/dataModel/' + encodeURIComponent(key)).pipe( map((ajaxResponse: AjaxResponse) => [ajaxResponse.response]) ); } protected getKeyOfItem(item: DataModel): string { return item.id; } protected getDependenciesOfItem(item: DataModel): string[] { return item.dependencies; } }
Implementation of GenericCache for DataModel

For ListNode, it can be implemented like this:

<>Copy
export class ListNode { id: string; label: string; hasRootNode?: string; // each list node holds the id of its root node ... } export class ListNodeCache extends GenericCache<ListNode> { protected requestItemFromServer(key: string, isDependency: boolean): Observable<ListNode[]> { if(!isDependency) { // a single list node was requested return ajax.get('https://www.mybackend.com/node/' + encodeURIComponent(key)).pipe( map((ajaxResponse: AjaxResponse) => [ajaxResponse.response]) ); } else { // root node was requested as a dependency of a list node // all list nodes are returned as an array return ajax.get('https://www.mybackend.com/list/' + encodeURIComponent(key)).pipe( map((ajaxResponse: AjaxResponse) => ajaxResponse.response) } } protected getKeyOfItem(item: ListNode): string { return item.id; } protected getDependenciesOfItem(item: ListNode): string[] { return item.hasRootNode ? [item.hasRootNode] : []; } }
Implementation of GenericCache for ListNode

Summing Up
Link to this section

RxJS's shareReplay operator is used to cache responses received from the server. These are replayed to each subscriber. For each item received from the server, its dependencies on other items are analysed. If not cached yet, dependencies are requested from the server automatically. If possible, requests for dependencies are optimised by using a different server route. The approach shown here applies generics so that the caching logic is independent of the type of response. For a specific type of response to be cached, an implementation can be provided extending the abstract class GenericCache.

Share

About the author

author_image

After my studies in humanities, I switched to software development. I am now developing software that helps maintain qualitative research data in the long-term.

author_image

About the author

Tobias Schweizer

After my studies in humanities, I switched to software development. I am now developing software that helps maintain qualitative research data in the long-term.

About the author

author_image

After my studies in humanities, I switched to software development. I am now developing software that helps maintain qualitative research data in the long-term.

Looking for a JS job?
NxAngularCli
NxAngularCli
NxAngularCli

Featured articles

Angularpost
13 September 20218 min read
Tracking user interaction area

Explore one of the most complex pieces of Taiga UI — ActiveZone directive that keeps an eye on what region user is working with. It touches on low-level native DOM events API, advanced RxJS and Dependency Injection, ShadowDOM and more!

Angularpost
13 September 20218 min read
Tracking user interaction area

Explore one of the most complex pieces of Taiga UI — ActiveZone directive that keeps an eye on what region user is working with. It touches on low-level native DOM events API, advanced RxJS and Dependency Injection, ShadowDOM and more!

Read more
AngularpostTracking user interaction area

13 September 2021

8 min read

Explore one of the most complex pieces of Taiga UI — ActiveZone directive that keeps an eye on what region user is working with. It touches on low-level native DOM events API, advanced RxJS and Dependency Injection, ShadowDOM and more!

Read more
Angularpost
7 September 202122 min read
Designing Angular architecture - Container-Presentation pattern

Designing architecture could be tricky, especially in the agile world, where requirement changes are frequent. So your design has to support that and provides extendibility without the need for serious modification. In such cases, you will find the Container-Presentation pattern instrumental.

micro frontendspost
6 September 202125 min read
Taking micro-frontends to the next level

The micro-frontends concept has been out there for quite a while. We’ve been using this architecture in Wix since around 2013, long before it was even given this name. In this article I’d like to share some of the things we did in order to evolve the concept of developing big scale micro-frontends.

micro frontendspost
6 September 202125 min read
Taking micro-frontends to the next level

The micro-frontends concept has been out there for quite a while. We’ve been using this architecture in Wix since around 2013, long before it was even given this name. In this article I’d like to share some of the things we did in order to evolve the concept of developing big scale micro-frontends.

Read more
micro frontendspostTaking micro-frontends to the next level

6 September 2021

25 min read

The micro-frontends concept has been out there for quite a while. We’ve been using this architecture in Wix since around 2013, long before it was even given this name. In this article I’d like to share some of the things we did in order to evolve the concept of developing big scale micro-frontends.

Read more