Negative Cache for Repeated Failed Lookups

Remember missing lookup results for a short TTL to avoid repeated expensive calls while distinguishing hits from misses.

TypescriptResilience

Code

snippet
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;
}