expiry-map
A drop-in Map implementation where entries automatically expire and free memory after a set TTL.
Repository Health
Technical Analysis
expiry-map extends JavaScript’s native Map with a time-to-live on every entry. Each key-value pair is stored alongside an expiration timestamp, and a background cleaner (powered by the companion map-age-cleaner package) removes expired entries so memory is reclaimed without any manual bookkeeping.
Because ExpiryMap implements the standard Map interface, it’s a near drop-in replacement anywhere a Map is used today — get, set, has, delete, size, iteration, and Symbol.iterator all behave the same way, with the single addition that stale entries silently disappear once their maxAge elapses.
What You Get
- Full Map-interface compatibility (get/set/has/delete/size/iteration)
- Per-entry TTL expiration computed from a single maxAge argument
- Automatic background cleanup of expired entries via map-age-cleaner
- Complete TypeScript typings with generics for key/value types
Common Use Cases
- Caching API responses or computed values with a fixed TTL
- Storing short-lived session tokens or auth credentials in memory
- Building debounce/rate-limit windows keyed by identifier
- Memoizing expensive function calls with automatic invalidation
Under The Hood
Architecture expiry-map is architecturally minimal: a single class, ExpiryMap, defined in source/index.ts, wraps a private native Map<K, Entry<V>> where each Entry pairs the stored value with a computed maxAge timestamp (Date.now() + maxAge, set in set()). All Map-interface methods (get, set, has, delete, size, keys, values, entries, forEach, Symbol.iterator) read from or write to this single private data field, and iteration is centralized through one private generator, createIterator, that all of values()/entries()/Symbol.iterator() delegate to for projection. Rather than implementing its own expiration sweep, the constructor bootstraps the companion package map-age-cleaner directly against the private data Map, delegating the actual eviction timer/cleanup mechanism to that dependency — a clean separation between “what an entry is” (this module) and “how stale entries get physically removed” (map-age-cleaner). Because every method touches the same single Map<K, Entry<V>> shape, changing that core abstraction would require touching nearly every method in the file, but the file itself is small enough (under 100 lines) that this coupling is low-risk.
Tech Stack The package targets ES6/CommonJS via tsc (tsconfig.json: target es6, module commonjs, strict true with noImplicitReturns/noUnusedLocals/noUnusedParameters all enabled), and ships only the compiled dist/index.js plus dist/index.d.ts to npm (package.json files field), keeping the published artifact tiny. Its sole runtime dependency is map-age-cleaner (^0.2.0), a sibling package by the same author responsible for the actual Map-pruning behavior. The dev toolchain is period-appropriate for a 2018-era Sam Verschueren utility: ava as the test runner, nyc for coverage instrumentation piped to codecov, tslint with the tslint-xo preset for linting (pre-dating the ecosystem’s move to ESLint), and del-cli for the pre-build clean step. CI (.github/workflows/main.yaml) runs the full test/lint suite across Node 8, 10, 12, 14, and 16 on every push and PR, verifying the package’s stated engines >=8 compatibility claim.
Code Quality Test coverage is genuine and reasonably thorough for the package’s surface area: source/test.ts (using ava plus the delay package to simulate real elapsed time) exercises the constructor, .size, .clear, .delete, .has, .get and iteration behavior, including edge cases like reading an entry mid-TTL versus after expiry. tslint is configured with the strict tslint-xo preset (only no-require-imports disabled, needed for the export = / import = require() CommonJS-interop style used throughout), and the TypeScript compiler runs in strict mode with noUnusedLocals/noUnusedParameters/noImplicitReturns all enabled, giving reasonably strong type safety guarantees despite the small file count. Naming is consistent with the native Map API it mirrors, which lowers cognitive load for consumers. The main gap is that the private Entry<V> wrapper and createIterator generator have no dedicated unit tests of their own — coverage is entirely behavioral, through the public Map-like surface — but for a two-file package this is a reasonable trade-off.
API Design expiry-map’s specific technical contribution is implementing the full standard Map interface (Symbol.toStringTag, Symbol.iterator, forEach, entries/keys/values as proper iterators) rather than a Map-like object with only get/set/has — meaning it is usable anywhere destructuring, spread, or for…of over a native Map is expected, unlike many hand-rolled TTL-cache implementations that only expose a get/set/delete subset. Its choice to delegate the actual pruning mechanism to a separate composable package (map-age-cleaner) rather than embedding a setInterval/setTimeout sweep is a deliberate, testable separation of concerns, letting the eviction strategy be reused independently by expiry-map’s sibling packages. That said, per-key TTL caching with lazy or background eviction is a well-established pattern (lru-cache, node-cache, quick-lru all solve overlapping problems), so this is a well-executed instance of a known idea rather than a novel technique.