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
anddelete
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>
andoptions: 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:
no-restricted-imports
to "disallow specified modules when loaded byimport
."no-restricted-modules
to "disallow specified modules when loaded byrequire
"; useful if you are still using CommonJS modules.
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)