cached()
cached() is a higher-order function that wraps any async function with a cache. Declare the caching strategy once; every subsequent call uses the cache automatically.
import { cached } from "@warlock.js/cache";
const getUser = cached( (id: number) => db.users.find(id), "user", "1h",);
await getUser(42); // cache miss → runs DB query, caches, returnsawait getUser(42); // cache hit → no DB callawait getUser.invalidate(42); // drop "user.42" from the cacheawait getUser(42); // miss againUses remember() under the hood — inherits stampede protection for free.
When to use cached vs remember
Section titled “When to use cached vs remember”| Task | Use |
|---|---|
| One-shot “get this value or compute it” | cache.remember() |
| Turn an existing function into a cached version you’ll call many times | cached() |
Three shapes
Section titled “Three shapes”All three put fn first and return the same wrapped function type.
1. Shorthand — prefix only
Section titled “1. Shorthand — prefix only”Driver’s default TTL. The wrapped function’s arguments are auto-appended to the prefix to derive the cache key.
const getFeatured = cached( () => db.products.findFeatured(), "featured-products",);// cache key: "featured-products"
const getUser = cached( (id: number) => db.users.find(id), "user",);// getUser(42) → cache key "user.42"2. Shorthand — prefix + TTL
Section titled “2. Shorthand — prefix + TTL”Same as #1, with an explicit TTL. Accepts number of seconds or a duration string.
const getUser = cached( (id: number) => db.users.find(id), "user", "1h",);
const getOrder = cached( (userId: number, orderId: string) => db.orders.find(userId, orderId), "orders", "10m",);// getOrder(42, "abc") → cache key "orders.42.abc"3. Options form — full control
Section titled “3. Options form — full control”Custom key function, tags, per-call driver override.
type Filters = { category: string; sort: string; page: number };
const searchProducts = cached( (filters: Filters) => db.products.search(filters), { key: (filters) => `products.search.${filters.category}.${filters.sort}`, ttl: "15m", tags: ["products"], driver: "redis", },);Use the options form when:
- The cache key shouldn’t include every argument (drop
pagefrom the key above so paginated calls share a cache entry). - You want to attach tags for bulk invalidation.
- You need to route this wrapper’s calls to a non-default driver.
Auto-key rules (shorthand only)
Section titled “Auto-key rules (shorthand only)”When you use the positional cached(fn, prefix) / cached(fn, prefix, ttl) form, keys are derived automatically:
| Args shape | Key |
|---|---|
| No args | prefix |
All primitives (string, number, boolean, bigint, null, undefined) | ${prefix}.${args.join(".")} |
| Any object / array arg | ${prefix}.${JSON.stringify(args)} |
Serialization throws (circular refs, BigInt nested in an object) | CacheConfigurationError |
Examples:
getUser(42) // "user.42"getUser("john") // "user.john"getOrder(42, "abc") // "orders.42.abc"getBy("john", null) // "user.john.null"searchBy({ q: "hello" }) // 'search.[{"q":"hello"}]'Caveats
Section titled “Caveats”- Order matters.
getOrder(42, "abc")andgetOrder("abc", 42)produce different keys. Date→ ISO string viaJSON.stringify. TwoDateobjects at the same millisecond share a key — correct.Map/Setserialize to{}viaJSON.stringify— that’s a footgun. Reach for the options form with a customkeyfn.- Very large args produce very large keys. Fine on Redis, terrible on file driver (absurd directory paths).
When the auto-key isn’t right, use the options form. key can project whatever subset of the args actually identifies the cached value.
Helpers on the wrapped function
Section titled “Helpers on the wrapped function”.invalidate(...args)
Section titled “.invalidate(...args)”Drop the cache entry for a specific argument combination — uses the same key function the wrapper uses internally, so callers don’t re-derive keys by hand.
const getUser = cached((id: number) => db.users.find(id), "user", "1h");
await getUser(42); // populates "user.42"await getUser.invalidate(42); // drops "user.42":::info Planned for v2.1
.refresh() (force-recompute) and .peek() (read without populating) are not shipping yet. If you need them, file it under domains/cache/backlog.md.
:::
Real-world examples
Section titled “Real-world examples”Memoize a DB lookup
Section titled “Memoize a DB lookup”const getUser = cached( (id: number) => db.users.findById(id), "user", "1h",);
// Call wherever you need a userconst user = await getUser(42);
// Invalidate after a writeawait db.users.update(42, patch);await getUser.invalidate(42);Bulk invalidation via tags
Section titled “Bulk invalidation via tags”const getUser = cached( (id: number) => db.users.findById(id), { key: (id) => `user.${id}`, ttl: "1h", tags: ["users"] },);
const getPosts = cached( (userId: number) => db.posts.findByUser(userId), { key: (userId) => `posts.by-user.${userId}`, ttl: "30m", tags: ["users", "posts"] },);
// After any user-table change, drop every wrapper's cache in one goawait cache.tags(["users"]).invalidate();Project a subset of args into the key
Section titled “Project a subset of args into the key”type Filters = { category: string; sort: string; page: number; pageSize: number };
// Key ignores `page` so paginated calls for the same filters share an entry.// (Only do this if your source function returns ALL pages — otherwise pages// would collide. Use this pattern thoughtfully.)const getCategoryMeta = cached( (filters: Filters) => db.categories.meta(filters.category), { key: (f) => `category.meta.${f.category}`, ttl: "1h", },);Cache a remote API call with tags
Section titled “Cache a remote API call with tags”const getWeather = cached( (city: string) => fetch(`https://api.weather.com/${city}`).then((r) => r.json()), { key: (city) => `weather.${city.toLowerCase()}`, ttl: "10m", tags: ["weather"] },);
// Refresh everyone after a scheduled synccron.every("10m", () => cache.tags(["weather"]).invalidate());Stampede behavior
Section titled “Stampede behavior”Inherited from remember(). When 100 concurrent calls hit the same wrapper with the same args on a cold cache, the underlying function runs once and the other 99 share its promise. Works within a single Node process; cross-process stampede safety still needs a distributed lock.
const getter = cached(expensiveQuery, "report", "1h");
// Underlying expensiveQuery runs exactly onceawait Promise.all(Array.from({ length: 100 }, () => getter(42)));Troubleshooting
Section titled “Troubleshooting”CacheConfigurationError: could not derive an auto-key from args— your args include something JSON.stringify can’t handle (circular ref,BigIntnested in an object). Switch to the options form with a customkeyfn that picks JSON-safe fields.- Two calls with the same args hit the source function — driver default TTL is
Infinityonly if you haven’t set one. Checkoptions.<driver>.ttl, or pass a TTL via the shorthand. .invalidate()doesn’t seem to work — verify you’re calling it with the same args shape the wrapper was called with. The key function needs matching inputs to produce the same key.- Huge cache keys on the file driver — the auto-key
JSON.stringifypath grows unbounded. Use a customkeyfn to project only what identifies the value.
Related Documentation
Section titled “Related Documentation”- Stampede Prevention —
remember(), the primitive under the hood - Set Options — the shape of the options object
cachedforwards toset() - Cache Tags — tag-based invalidation
- Errors —
CacheConfigurationErrorand when it fires