TTL Map With Lazy Expiry And Sweep

A generic Map with per-entry time to live, lazy expiration on reads, manual sweeping, expiry callbacks and bounded insertion-order eviction.

TypescriptData Integrity

Code

snippet
export interface TtlMapOptions<K, V> {
  ttlMs: number;
  now?: () => number;
  maxSize?: number;
  onExpire?: (key: K, value: V) => void;
}

interface Entry<V> {
  value: V;
  expiresAt: number;
}

export class TtlMap<K, V> {
  private readonly entries = new Map<K, Entry<V>>();
  private readonly ttlMs: number;
  private readonly now: () => number;
  private readonly maxSize: number;
  private readonly onExpire?: (key: K, value: V) => void;

  constructor(options: TtlMapOptions<K, V>) {
    if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
      throw new RangeError("ttlMs must be a positive finite number");
    }
    const maxSize = options.maxSize ?? Number.POSITIVE_INFINITY;
    if (maxSize !== Number.POSITIVE_INFINITY && (!Number.isInteger(maxSize) || maxSize < 1)) {
      throw new RangeError("maxSize must be a positive integer");
    }
    this.ttlMs = options.ttlMs;
    this.now = options.now ?? Date.now;
    this.maxSize = maxSize;
    this.onExpire = options.onExpire;
  }

  set(key: K, value: V, ttlMs: number = this.ttlMs): this {
    if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
      throw new RangeError("ttlMs must be a positive finite number");
    }
    if (this.entries.has(key)) {
      this.removeIfExpired(key);
    } else if (this.entries.size >= this.maxSize) {
      this.evictOldest();
    }
    this.entries.set(key, { value, expiresAt: this.now() + ttlMs });
    return this;
  }

  get(key: K): V | undefined {
    const entry = this.entries.get(key);
    if (!entry) return undefined;
    if (this.isExpired(entry)) {
      this.expire(key, entry);
      return undefined;
    }
    return entry.value;
  }

  has(key: K): boolean {
    const entry = this.entries.get(key);
    if (!entry) return false;
    if (this.isExpired(entry)) {
      this.expire(key, entry);
      return false;
    }
    return true;
  }

  delete(key: K): boolean {
    return this.entries.delete(key);
  }

  sweep(): number {
    const now = this.now();
    let removed = 0;
    for (const [key, entry] of this.entries) {
      if (now >= entry.expiresAt) {
        this.entries.delete(key);
        this.notify(key, entry);
        removed += 1;
      }
    }
    return removed;
  }

  get size(): number {
    this.sweep();
    return this.entries.size;
  }

  private isExpired(entry: Entry<V>): boolean {
    return this.now() >= entry.expiresAt;
  }

  private expire(key: K, entry: Entry<V>): void {
    this.entries.delete(key);
    this.notify(key, entry);
  }

  private notify(key: K, entry: Entry<V>): void {
    this.onExpire?.(key, entry.value);
  }

  private removeIfExpired(key: K): void {
    const entry = this.entries.get(key);
    if (entry && this.isExpired(entry)) this.expire(key, entry);
  }

  private evictOldest(): void {
    const first = this.entries.keys().next();
    if (!first.done) this.entries.delete(first.value);
  }
}