Negative Cache for Repeated Failed Lookups
Remember missing lookup results for a short TTL to avoid repeated expensive calls while distinguishing hits from misses.
Code
export interface CacheLookup<V> {
hit: boolean;
value?: V;
}
interface CacheEntry<V> {
value: V | null;
expiresAt: number;
}
export class NegativeCache<K, V> {
private readonly entries = new Map<K, CacheEntry<V>>();
private readonly ttlMs: number;
private readonly now: () => number;
constructor(ttlMs: number, now: () => number = () => Date.now()) {
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
if (typeof now !== "function") throw new TypeError("now must be a function");
this.ttlMs = ttlMs;
this.now = now;
}
get(key: K): CacheLookup<V> {
const entry = this.entries.get(key);
if (!entry) return { hit: false };
if (this.now() >= entry.expiresAt) {
this.entries.delete(key);
return { hit: false };
}
if (entry.value === null) return { hit: true };
return { hit: true, value: entry.value };
}
setMiss(key: K): void {
this.entries.set(key, { value: null, expiresAt: this.now() + this.ttlMs });
}
setValue(key: K, value: V): void {
if (value === null) throw new TypeError("value must not be null; use setMiss");
this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
}
delete(key: K): boolean {
return this.entries.delete(key);
}
get size(): number {
return this.entries.size;
}
}
export async function withNegativeCache<K, V>(
cache: NegativeCache<K, V>,
key: K,
loader: () => Promise<V | null>,
): Promise<V | null> {
if (typeof loader !== "function") throw new TypeError("loader must be a function");
const cached = cache.get(key);
if (cached.hit) return cached.value ?? null;
const value = await loader();
if (value === null) cache.setMiss(key);
else cache.setValue(key, value);
return value;
}Report a problem with this exampleOpens a pre-filled issue on GitHub.