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.
Code
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);
}
}Report a problem with this exampleOpens a pre-filled issue on GitHub.