Adding type-safety to Node-Cache

Published on in ESLint, JavaScript and TypeScript

Last updated on

Node-Cache (a JS library) has generic get and set methods, but it's easy to accidentally misuse them. I made a type-safer wrapper around the library.

Table of contents

Problem / Context

Node-Cache is a caching module for Node.js:

A simple caching module that has set, get and delete methods and works a little bit like memcached. Keys can have a timeout (ttl) after which they expire and are deleted from the cache. All keys are stored in a single object so the practical limit is at around 1m keys.

Node-Cache's get and set methods are generic, which is nice:

import NodeCache from 'node-cache'

const myCache = new NodeCache(options)

myCache.set<Foo>(cacheKey, value)
const foo = myCache.get<Foo>(cacheKey)

But the constructor is not generic, which is problematic; you can accidentally use wrong types when getting and setting values.

Example case

I was investigating a weird bug and was running out of ideas. Finally I noticed that a single cache was accidentally being used for two different things:

import NodeCache from 'node-cache'

const fooCache = new NodeCache(options)
const barCache = new NodeCache(options)

export function getFoo(id: number) {
  if (fooCache.has(id)) {
    return fooCache.get<Foo>(id)
  }

  const value = getFooFromSomewhere()

  fooCache.set<Foo>(id, value)
  return value
}

export function getBar(id: number) {
  if (barCache.has(id)) {
    return barCache.get<Bar>(id)
  }

  const value = getBarFromSomewhere()

  // ⚠️ Whoops! Accidentally using the wrong cache here
  fooCache.set<Bar>(id, value)
  return value
}

Solution

Goal

Ideally the constructor would be generic, so that a cache can contain only certain types:

const fooCache = new NodeCache<Foo>(options)

const foo = fooCache.get(cacheKey) //=> Foo | undefined
fooCache.set(cacheKey, value) // `value` must be of type `Foo`

Currently this is not possible; GitHub issue #273 is asking for this.

The issue was created in December 2021, and the latest commit to the project was made in November 2021, so the issue might never get solved.

I solved it myself by creating a type-safer wrapper around Node-Cache.

Code

// eslint-disable-next-line no-restricted-imports
import NodeCache, { Key, Options, ValueSetItem } from 'node-cache'

/**
 * A type-safer wrapper around [Node-Cache](https://github.com/node-cache/node-cache).
 *
 * Via https://mtsknn.fi/blog/type-safer-node-cache/
 *
 * @example
 * const myCache = Cache<MyType>(options)
 * myCache.set('foo', value)
 * myCache.get('foo')
 *
 * @example
 * // Error: Must provide a type parameter
 * const myCache = Cache(options)
 */
export function Cache<T = void>(
  // A fancy way to make the `T` parameter required:
  // https://stackoverflow.com/a/51173322/1079869
  options: Options &
    (T extends void
      ? 'Error: Must provide a type parameter (`Cache({})` -> `Cache<T>({})`)'
      : Options)
) {
  const cache = new NodeCache(options)

  return {
    // Type casting required to not lose some methods (e.g. `del`)
    ...(cache as Required<typeof cache>),

    // Only the generic methods of Node-Cache are overridden
    get: (key: Key) => cache.get<T>(key),
    mget: (keys: Key[]) => cache.mget<T>(keys),
    mset: (keyValueSet: ValueSetItem<T>[]) => cache.mset<T>(keyValueSet),
    set: (key: Key, value: T, ttl?: number | string) =>
      cache.set<T>(key, value, ttl as number | string),
    take: (key: Key) => cache.take<T>(key),
  }
}

Notes

  • Apparently there's no built-in way to mark generic type parameters as required. So instead of using <T> and options: Options:

    export function Cache<T>(options: Options) {
      // ...
    }
    

    ...I'm using a fancy way to make the generic T parameter required:

    export function Cache<T = void>(
      options: Options &
        (T extends void
          ? 'Error: Must provide a type parameter (`Cache({})` -> `Cache<T>({})`)'
          : Options)
    ) {
      // ...
    }
    
    Cache({})
    //    ^? Argument of type '{}'
    //       is not assignable to parameter of type
    //       'Options & "Error: Must provide a type parameter (`Cache({})` -> `Cache<T>({})`)"'.
    
    Cache<Foo>({}) // OK
    

    Unfortunately this also makes the options parameter required. Passing an empty object is OK.

    Not ideal, but better than nothing.

  • For some reason the spread syntax causes some methods to be lost:

    export function Cache<T>(options: Options) {
      const cache = new NodeCache(options)
    
      return {
        ...cache,
        get: (key: Key) => cache.get<T>(key),
        // ...
      }
    }
    
    const fooCache = Cache<Foo>({})
    fooCache.del('bar')
    //       ^? Property 'del' does not exist on type '...'.
    

    Type casting fixes the problem (I don't know why):

    return {
      ...(cache as Required<typeof cache>),
      get: (key: Key) => cache.get<T>(key),
      // ...
    }
    

    At first I used ...(cache as Omit<typeof cache, 'get' | 'mget' | 'mset' | 'set' | 'take'>), but ...(cache as Required<typeof cache>) and ...(cache as Readonly<typeof cache>) seem to work as well.

  • Only the generic methods of Node-Cache need to be overridden; there's nothing to fix in the non-generic methods (unless I'm missing something).

  • You might want to copy the JSDoc comments from Node-Cache's index.d.ts. For example (comment slightly clarified by me):

    return {
      // ...
    
      /**
       * Get a cached key and remove it from the cache.
       * Equivalent to calling `get(key)` + `del(key)`.
       *
       * Useful for implementing "single use" mechanism such as OTP (one-time password),
       * where once a value is read it will become obsolete.
       */
      take: (key: Key) => cache.take<T>(key),
    }
    

Bonus: ESLint

One more step: use ESLint to prevent importing Node-Cache so that everyone will use the type-safer wrapper instead.

There are two built-in ESLint rules for this:

Like so:

// .eslintc

{
  "rules": {
    "no-restricted-imports": [
      "error",
      { "name": "node-cache", "message": "Import the `Cache` utility instead." }
    ],
    "no-restricted-modules": [
      "error",
      { "name": "node-cache", "message": "Import the `Cache` utility instead." }
    ]
  }
}

Example:

// foo.ts

import NodeCache from 'node-cache'
//=> 'node-cache' import is restricted from being used.
//   Import the `Cache` utility instead.
//   eslint(no-restricted-imports)