A strongly typed caching framework that works with any engine. In-memory and Redis adapters included.
It provides a general Cache
class that interacts with cache adapters, which are responsible for communicating with the cache engine (e.g. Redis). The package comes with a cache adapter for an in-memory cache that saves its content to the disk, one that does absolutely nothing (no values saved and never returns a value) and a cache adapter for Redis.
To use several cache instances on the same cache engine, every cache accepts a prefix parameter that is prepended to all keys before they are stored. This allows for different namespaces and versioning stored in the same cache engine.
Values are serialized to string for storage in the cache. By default, JSON.stringify
/JSON.parse
are used, but custom serializers may be provided for serializing e.g. with Protocol Buffers.
The package is published as @jeengbe/cache
. Versions follow Semantic Versioning.
// service/index.ts
export interface MyService {
doThing(param: number): Promise<string>;
}
// service/cached.ts
type MyServiceCacheTypes = {
[K in `thing-${number}`]: string;
};
export class CachedMyService implements MyService {
constructor(
private readonly cache: Cache<MyServiceCacheTypes>,
private readonly delegate: MyService,
) {}
async doThing(param: number): Promise<string> {
return await this.cache.cached(`thing-${param}`, () =>
this.delegate.doThing(param),
);
}
}
// init.ts
const cacheAdapter = new RedisCacheAdapter(new Redis(redisConfig));
const cache = new Cache<MyServiceCacheTypes>(cacheAdapter);
The generic Cache
class takes a type parameter that dictates which cache keys correspond to which values.
First, decare the type as an object where the object properties are available cache keys and values the respective values in the cache. Use template literal types to account for patterns like `cached-${id}`
in your keys. Intersect several mapped types to merge several objects with template literal types:
type XCacheTypes = {
[K in `cached-a-${string}`]: number;
} & {
[K in `cached-b-${number}`]: string;
} & {
'general-c': string;
'general-d': boolean;
};
A cache with the above types accepts the following:
- Keys that start with "cached-a-" may cache a number.
- Keys that start with "cached-b-" followed by a number may cache a string.
- The key "general-c" may cache a string.
- The key "general-d" may cache a boolean.
All read/write operations on corresponding instance are strongly typed. To create a new cache object, instantiate the class with a suiting cache adapter and optionally a prefix.
import { Cache, VoidCacheAdapter } from '@jeengbe/cache';
const xCache = new Cache<XCacheTypes>(new VoidCacheAdapter(), 'x:1');
Consider this context for all following code examples in this document:
type Result = { id: string; calculated: number };
declare const resultCache: Cache<{
[K in `expensive-${string}`]: Result;
}>;
Use get
to get the value for a single cached key.
To get the values for several keys in one operation, use mget
.
If you want to get the value, and, if none present, calculate and store a new one, consider using cached
/mcached
instead.
Use set
to set the cached value for a single key.
To set the cached values for several keys, use mset
.
Unlike conventional cache packages, mset
takes an array of values and a function to determine the cache key for each value.
declare const items: readonly Result[];
await resultCache.mset(items, (item) => `expensive-${item.id}`, '1d');
Use del
to delete the cached value for a single key.
To delete the cached values for several keys, use mdel
.
Use pdel
to delete the cached values for all keys that match the given glob-style pattern.
Please note that the pattern is not fully typed and can be any string. PRs welcome. :)
Use clear
to delete the cached values for all keys.
Use has
to check whether there exists a cached value for a single key.
To delete whether several keys have a cached value, use mhas
. mhas
only reports whether all of the provided keys have a value cached and returns no inforation about which/how many of the given keys have no value cached.
Use cached
to get the cached value for a cache, and if the value is not in the cache, run a function to produce a value and cache that.
Because this is a commonly used pattern, this package provides a convenience method for this.
Use mcached
if you want to get several cached values and only compute those for which there is no value stored in the cache. It takes an array of data, from which each the cache key is generated. If at least one key has no cached value, the producer is called with an array of those data items for whose key no value was cached.
Note that this is no atomic operation and the key is in no way locked while the producer is awaited.
declare function expensiveFunction(id: string): Promise<Result>;
declare const id: string;
const result = await resultCache.cached(
// ^? Result
`expensive-${id}`,
() => expensiveFunction(id),
'1d',
);
declare function expensiveBatchFunction(
ids: readonly string[],
): Promise<Result[]>;
declare const ids: string[];
const results = await resultCache.mcached(
// ^? Result[]
ids,
(id) => `expensive-${id}`,
(m) => expensiveBatchFunction(m),
'1d',
);
import { RedisCacheAdapter } from '@jeengbe/cache';
import { Redis } from 'ioredis';
const cacheAdapter = new RedisCacheAdapter(new Redis(redisConfig));
Stores no values, get operations always return undefined and has always returns false.
import { VoidCacheAdapter } from '@jeengbe/cache';
const cacheAdapter = new VoidCacheAdapter();
Keeps the values in memory.
The package @isaacs/ttlcache
can be used for the cache implementation.
import TtlCache from '@isaacs/ttlcache';
import { MemoryCacheAdapter } from '@jeengbe/cache';
const cacheAdapter = new MemoryCacheAdapter(new TtlCache());
It is also possible to back up the memory after every write operation. To do that, construct the adapter with a cache backup saver. To save the memory to disk, pass it a DiskCacheBackupSaver
as shown:
import TtlCache from '@isaacs/ttlcache';
import { DiskCacheBackupSaver, MemoryCacheAdapter } from '@jeengbe/cache';
const cacheAdapter = new MemoryCacheAdapter(
new TtlCache(),
new DiskCacheBackupSaver(diskCacheBackupLocation),
);
Keeps the values in memory, but ignores TTL i.e. values to not expire.
Ideal for unit tests that involve a cache so that you don't have to mock the cache.
import { NoTtlMemoryCacheAdapter } from '@jeengbe/cache';
const cacheAdapter = new NoTtlMemoryCacheAdapter();
All methods that take a duration (set
/cached
, etc.) accept either a ttl in milliseconds, or any duration string that can be parsed by the ms
package.
For the operations mget
, mdel
, mhas
, you may run into compiler errors if you map over an input array normally (see example below). To fix this, add an as const
assertion to ensure that mapped keys are properly typed.
declare const ids: string[];
// Missing 'as const':
await resultCache.mget(
ids.map((id) => `expensive-${id}`),
// ~~~~~ Type 'string' is not assignable to type '`expensive-${string}`'.
);
// Correctly typed:
await resultCache.mget(ids.map((id) => `expensive-${id}` as const));
The longer explanation is that for a variety of reasons, ids.map((id) => `expensive-${id}`)
results in a string[]
instead of exactly `expensive-${string}`[]
. So when string[]
is used as keys for a strongly typed signature like mget
, the compiler (rightfully so) complains. By changing it to ids.map((id) => `expensive-${id}` as const)
, we make the type as exact as possible, which then gives the explicit string types we need.
The reason as const
is not necessary for mset
, mcached
is that the compiler is able to infer the as const
automatically here. Normally, (item) => `expensive-${item.id}`
results in () => string
, but because the compiler expects an explicit () => `expensive-${string}`
it also tries to determine a stricter signature for the method, which satisfies the strongly typed signature.
In theory, this would also work with the above described map
limitation, but the compiler does not check that deep, so the inferring from mget
signature -> map
return type -> map
callback is not made, and .map
results in string[]
.
declare const items: readonly Result[];
await resultCache.mset(items, (item) => `expensive-${item.id}`, '1d');
Alternatively, you can pass a type parameter to map
, but that's less elegant if you ask me:
declare const ids: string[];
await resultCache.mget(
ids.map<`expensive-${string}`>((id) => `expensive-${id}`),
);