npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hitori/memoizee-decorator

v2.1.0

Published

Memoization decorator for class methods and getters, with typed cache-key checking and WeakMap-backed caches

Readme

@hitorisensei/memoizee-decorator

Description

A memoization decorator for class methods and getters. Caches are stored per instance and are garbage-collectible: once an instance (or, in primitive: false mode, a cache key) is no longer referenced elsewhere, its cache is collectible too.

The cacheKey option is type-checked against the decorated method's own parameters, so a cacheKey that doesn't match the method it's attached to is a compile-time error.

Note: version 2 is a breaking rewrite. See Migrating from v1 below.

Installation

pnpm add @hitorisensei/memoizee-decorator

Requires TypeScript >= 5.0 with experimentalDecorators enabled, and Node >= 20 (for WeakMap/WeakSet symbol keys, used by primitive: false).

Usage

import { Memoize } from '@hitorisensei/memoizee-decorator';

class MyClass {
  @Memoize()
  public getMyValue(): number {
    console.log('My value is calculated');
    return someExpensiveCalculation();
  }
}

const myClass = new MyClass();
myClass.getMyValue(); // logs 'My value is calculated' once
myClass.getMyValue(); // returns the cached value

Getters work the same way:

class MyClass {
  @Memoize()
  get myValue(): number {
    return someExpensiveCalculation();
  }
}

For code that isn't a class method — a plain function — use memoize instead of @Memoize. It takes the same options (see below) and the same cacheKey type-checking, but there's no instance to scope the cache to: the returned function has one cache for its lifetime, and exposes .clear() directly rather than going through clearMemoization:

import { memoize } from '@hitorisensei/memoizee-decorator';

const getMyValue = memoize((id: string) => {
  console.log('My value is calculated for', id);
  return someExpensiveCalculation(id);
});

getMyValue('a'); // logs 'My value is calculated for a' once
getMyValue('a'); // returns the cached value

getMyValue.clear(); // clears its cache

Cache key

By default, the cache key is the method's first argument, used directly (no hashing). This keeps memoization cheap and predictable, but it means the type system needs to know what a valid key looks like:

  • primitive: true (the default): the first parameter must be usable as a plain object/Map key (string | number | symbol).
  • primitive: false: the first parameter must be assignable to WeakKey (an object or symbol) — see WeakMap mode below.
class MyClass {
  @Memoize()
  public getMyValue(id: string): number {
    console.log('My value is calculated for', id);
    return someExpensiveCalculation(id);
  }
}

If a method takes more than one argument and the cache should depend on more (or different) data, pass cacheKey. It receives the same arguments as the decorated method (or a leading subset of them — extra trailing parameters can just be omitted) and must return a string (primitive: true) or a WeakKey (primitive: false), or undefined to skip caching that call entirely:

class MyClass {
  @Memoize({
    cacheKey: (a: number, b: number) => `${a}:${b}`,
  })
  public add(a: number, b: number): number {
    return a + b;
  }
}

TypeScript checks cacheKey against the decorated method's parameter types, so this won't compile:

class MyClass {
  @Memoize({
    // Error: `id` is typed as `string` here, but `getMyValue`'s first parameter is `number`.
    cacheKey: (id: string) => id,
  })
  public getMyValue(id: number): number {
    return someExpensiveCalculation(id);
  }
}

Options

interface MemoizeOptions {
  /** Use a Map (default) or a WeakMap. See "WeakMap mode" below. */
  primitive?: boolean; // default: true
  /** Maximum number of cached entries; least-recently-used ones are evicted first. */
  max?: number;
  /** Milliseconds after which a cached entry expires. */
  maxAge?: number;
  /**
   * If the decorated method returns a Promise, evict its cache entry when
   * that promise rejects, so the next call retries instead of replaying the
   * same rejection forever. No effect on non-Promise return values.
   */
  evictOnReject?: boolean; // default: false
  /** Computes the cache key from the call arguments. */
  cacheKey?: (...args) => string | WeakKey | undefined;
  /**
   * Computes a per-call override for how long this particular result should
   * stay fresh (in ms), from the result itself and the call's own
   * arguments, instead of one fixed `maxAge` for every call. Return
   * `undefined` to fall back to `maxAge` (or no expiry, if that's unset
   * too) for that call. See "Content-derived expiry" below.
   */
  deriveMaxAge?: (response, parameters) => number | undefined;
  /**
   * Enables stale-while-revalidate: once a cached entry is older than
   * `minStaleAge`, the next access still returns it immediately, but also
   * triggers `revalidate` in the background to decide whether — and how —
   * to refresh it. See "Stale-while-revalidate" below.
   */
  staleWhileRevalidate?: {
    minStaleAge: number;
    deriveMinStaleAge?: (response, parameters) => number | undefined;
    revalidate: (
      staleValue,
      ...args
    ) => StaleWhileRevalidateDecision | Promise<StaleWhileRevalidateDecision>;
  };
}

Async methods

By default, a memoized async method caches whatever it returns — the Promise itself — exactly like any other return value. Concurrent calls with the same key naturally share that one in-flight promise, but if it rejects, the rejection is cached too: every subsequent call with that key gets the same rejected promise back until the cache is cleared. Set evictOnReject: true to evict the entry on rejection instead, so the next call retries:

class Api {
  @Memoize({ evictOnReject: true })
  public async fetchUser(id: string): Promise<User> {
    return fetch(`/users/${id}`).then((res) => res.json());
  }
}

Content-derived expiry (deriveMaxAge)

maxAge applies the same fixed lifetime to every call. deriveMaxAge computes it per call instead, from the result and the call's own arguments — the natural fit whenever the source of the data already says how long it's good for, like an HTTP response's own Cache-Control: max-age:

class Api {
  @Memoize({
    deriveMaxAge: (response: Response) => {
      const match = response.headers.get('cache-control')?.match(/max-age=(\d+)/);
      return match ? Number(match[1]) * 1000 : undefined; // undefined => fall back to maxAge (or no expiry)
    },
  })
  public async fetchListing(url: string): Promise<Response> {
    return fetch(url);
  }
}

For an async method, deriveMaxAge always receives the resolved value, never the pending Promise — it runs once the call settles, correcting that entry's expiry in place. The value is still cached immediately when the call is made (under whatever maxAge would otherwise apply, or no expiry), so concurrent calls for the same key keep sharing the one in-flight Promise exactly as they do without deriveMaxAge; only the expiry is adjusted after the fact, never the value.

Returning undefined opts that one call out of the override, falling back to maxAge (or no expiry if maxAge is also unset) — useful for a response that doesn't carry its own freshness signal, or explicitly says not to cache at all (pair that case with a very small deriveMaxAge return, or evict it directly with delete/clearMemoization once you know not to keep it).

Stale-while-revalidate

maxAge/deriveMaxAge govern real eviction: once an entry's time is up, it's gone, and the next call blocks on a fresh one. staleWhileRevalidate is a different, independent policy layered on top, for when it's better to keep serving something a little old than to make a caller wait: once an entry is older than minStaleAge, it's still returned immediately on the next access, but that access also triggers revalidate in the background — the caller never waits on it.

class Api {
  @Memoize({
    staleWhileRevalidate: {
      minStaleAge: 60_000,
      revalidate: async (staleUser: User, id: string) => {
        const check = await fetch(`/users/${id}/version`, {
          headers: { 'if-none-match': staleUser.etag }, // the stale value can drive a conditional request
        });
        if (!check.ok) return { action: 'keep' }; // upstream trouble - keep serving what we have
        if (check.status === 304) return { action: 'keep' }; // confirmed nothing changed
        return { action: 'refresh' }; // something changed - go fetch the real thing
      },
    },
  })
  public async fetchUser(id: string): Promise<User> {
    return fetch(`/users/${id}`).then((res) => res.json());
  }
}

revalidate receives the currently-cached stale value, followed by the same arguments the memoized method was called with, and returns (or resolves to) a StaleWhileRevalidateDecision. For an async method this is always the resolved value, never a Promise — even though the cache's own entry for it is the Promise the method returned (that's what lets concurrent callers keep sharing it).

type StaleWhileRevalidateDecision<T> =
  | { action: 'refresh' }
  | { action: 'keep'; minStaleAge?: number }
  | { action: 'replace'; value: T };
  • refreshrevalidate doesn't have the new value itself; call the memoized method again to get it. The cache entry is replaced once that call succeeds; a failure (a thrown error, or a rejected Promise) leaves the stale entry exactly as it was. The stale value keeps being served for the entire duration of that call — it's only ever swapped in once the call has actually resolved, never while it's still pending.
  • keep — nothing to change (the check above confirmed nothing's new, or the upstream source failed and there's nothing better to do than keep going). minStaleAge, if given, overrides just this entry's staleness window for next time; omitted, it falls back to the option's own minStaleAge.
  • replacerevalidate already obtained the complete new value in the course of checking (a cheap check that happens to double as the real fetch, for instance) — install it directly, no need to call the memoized method again.

While one revalidate call is in flight for a given entry, further accesses keep returning the stale value without starting another one — there's never more than one check running per entry at a time. deriveMaxAge, if also configured, still applies to whatever a 'refresh'/'replace' produces, exactly as it would for a normal call.

minStaleAge is one fixed window for every entry. deriveMinStaleAge computes it per call instead, the same way deriveMaxAge does for maxAge — from the freshly produced response and the call's own arguments:

class Api {
  @Memoize({
    staleWhileRevalidate: {
      minStaleAge: 60_000, // fallback, used when deriveMinStaleAge returns undefined
      deriveMinStaleAge: (response: Response) => {
        const match = response.headers.get('cache-control')?.match(/max-age=(\d+)/);
        return match ? Number(match[1]) * 1000 : undefined;
      },
      revalidate: async (staleResponse: Response, url: string) => {
        /* ... */
      },
    },
  })
  public async fetchListing(url: string): Promise<Response> {
    return fetch(url);
  }
}

It runs whenever a fresh value is stored — the initial call, and a 'refresh'/'replace' outcome — never for a 'keep' (which has no new response to derive from; use its own minStaleAge field for that instead). For an async method it always receives the resolved value, applied the same way deriveMaxAge is: correcting the entry in place once the call settles. Returning Number.POSITIVE_INFINITY effectively pins an entry as always-fresh — revalidate is never triggered for it until something else (a set, a clear) replaces the entry.

Direct cache access

The function returned by memoize exposes has, peek, set, and delete, alongside clear, for reading or steering the cache without going through a call to the memoized function itself (and therefore without risking triggering a real computation):

const getUser = memoize((id: string) => fetchUser(id));

getUser.has('1'); // false — nothing cached yet, and this doesn't call fetchUser
getUser.peek('1'); // undefined — same as has(), but reads the value instead of a boolean

getUser('1'); // actually calls fetchUser('1') and caches the result

getUser.has('1'); // true
getUser.peek('1'); // the cached value (or, for an async function, the same Promise a concurrent call would get)

getUser.set('1', freshlyFetchedUser); // installs a value getUser never computed itself
getUser.set('1', freshlyFetchedUser, 30_000); // ...with its own expiry, overriding maxAge for just this entry
getUser.delete('1'); // evicts just this one key — clear() for a single entry, unlike clear() which empties everything

set does not run deriveMaxAge — that only ever applies to a value the memoized function itself produced, not one supplied directly.

peek/set/delete all key off the same thing a real call would: the cacheKey result if one is configured, otherwise the first argument. peek/has apply the same maxAge expiry check a real call does, so an expired entry reads as absent rather than returning a stale value.

This is aimed at code that wants to layer its own policy on top of the cache — most notably a stale-while-revalidate pattern: peek for a value to serve immediately while independently deciding whether to also refresh it, set to install what that refresh produced in the cache's place, and delete to evict just the one entry a particular response said not to keep, all without forcing a real (re-)computation through the memoized function as a side effect of managing that policy.

@Memoize-decorated methods and getters get the same has/peek/set/delete/clear methods, just reached differently: there's no instance yet at decoration time for them to be attached to directly, so fetch the per-instance controller with getCacheController instead:

import { getCacheController, Memoize } from '@hitorisensei/memoizee-decorator';

class UserService {
  @Memoize()
  getUser(id: string) {
    return fetchUser(id);
  }
}

const service = new UserService();
const cache = getCacheController(service, 'getUser'); // undefined until getUser has been called once

service.getUser('1');
getCacheController(service, 'getUser')?.peek('1'); // the cached value

getCacheController returns undefined until the method has actually been called at least once for that instance (the controller is created lazily, on first call) — unlike memoize, where the cache exists as soon as the function is created. For evicting a single entry without holding onto the controller yourself, clearMemoization(instance, methodName, key) (below) is usually more convenient.

If checking for undefined every time is more ceremony than you want, use getOrCreateCacheController instead — it creates the controller on the spot if the method hasn't been called yet, so it always returns one:

import { getOrCreateCacheController, Memoize } from '@hitorisensei/memoizee-decorator';

getOrCreateCacheController(service, 'getUser').peek('1'); // undefined, no `?.` needed

It looks up the primitive/max/maxAge options @Memoize was configured with itself (recorded at decoration time), so there's nothing extra to pass — propertyKey just needs to name an @Memoize-decorated member of instance's own class, or it throws.

WeakMap mode (primitive: false)

When the cache key is (or contains) an object — for example, memoizing per-entity rather than per-primitive-id — set primitive: false. The cache is then backed by a WeakMap, so entries are collected automatically once their key is no longer referenced anywhere else:

class Repository {
  @Memoize({ primitive: false })
  public summarize(entity: Entity): Summary {
    return computeSummary(entity);
  }
}

Combining primitive: false with max still works (least-recently-used eviction), but note that the max most-recently-used keys are then held strongly (by design, so they can be evicted in order) until they're evicted or the cache is cleared.

Clearing memoization

import { clearMemoization, Memoize } from '@hitorisensei/memoizee-decorator';

class MyClass {
  @Memoize()
  public getMyValue(): number {
    console.log('My value is calculated');
    return someExpensiveCalculation();
  }
}

const myClass = new MyClass();
myClass.getMyValue(); // logs 'My value is calculated' once
myClass.getMyValue(); // returns the cached value

clearMemoization(myClass, 'getMyValue'); // clears just this method's cache
myClass.getMyValue(); // logs 'My value is calculated' again

clearMemoization(myClass); // clears every memoized method/getter on myClass

methodName is checked against the keys of the instance's own type, so passing a name that doesn't exist on the class is a compile-time error.

Pass a third argument to evict a single cache entry instead of the whole method's cache — the same key a real call would use: the cacheKey result if one is configured, otherwise the first argument. methodName is required whenever key is given, since a key only makes sense scoped to one member:

class UserService {
  @Memoize()
  getUser(id: string) {
    console.log('fetching', id);
    return fetchUser(id);
  }
}

const service = new UserService();
service.getUser('1');
service.getUser('2');

clearMemoization(service, 'getUser', '1'); // evicts just '1', '2' stays cached

service.getUser('1'); // logs 'fetching 1' again
service.getUser('2'); // still cached, no log

Caches are looked up through metadata attached to the instance rather than through instance[methodName] directly, so clearMemoization still finds the right cache even if another decorator wraps the memoized method afterwards:

class MyClass {
  @LogCalls() // wraps the memoized method — clearMemoization still works
  @Memoize()
  public getMyValue(): number {
    return someExpensiveCalculation();
  }
}

Memory management

Memoized caches are stored as reflect-metadata attached directly to each instance, not in any structure that outlives it. Once an instance is no longer referenced anywhere, it — and every cache it held — becomes eligible for garbage collection along with it. There are no background timers: maxAge expiry is checked lazily, on read.

Migrating from v1

  • bug fix: v1 applied memoizee once to the shared prototype method (the standard pattern for a method decorator), and memoizee has no notion of this — the cache key was based on arguments alone. That means calling the same memoized method with the same arguments on two different instances would have returned the first instance's cached result to the second. This wasn't covered by v1's test suite. v2 keys caches per instance from the ground up, so this can't happen; there's nothing to configure.
  • The runtime dependency on memoizee and hash-sum is gone; caching is implemented directly in this package.
  • normalizer is renamed to cacheKey and is now the only supported shape (a function — memoizee's string-normalizer/multi-form options aren't supported), checked against the decorated method's parameter types. There's no normalizer alias; rename it at the call site.
  • The default cache key is now the method's first argument only (previously all arguments were hashed together with hash-sum). Methods that rely on more than one argument need an explicit cacheKey.
  • clearMemoization now takes (instance, methodName?) instead of a direct method reference: clearMemoization(instance, 'methodName') instead of clearMemoization(instance.methodName).
  • New primitive: false mode backs the cache with a WeakMap for object keys.