Implement memoize with cache invalidation
Build memoize with cache invalidation. The interviewer expects a small, reusable utility with clear behavior under repeated calls and invalid inputs.
Answer Strategy
Memoize is interview-friendly because the surface looks small: cache the return value of a pure function keyed by its arguments. The senior part is what is missing from that one-line description: how arguments become a key, how the cache shrinks, when entries become stale, and how the caller invalidates a single key without throwing away the whole cache.
Separate four owners before coding. The wrapped function owns its work. The serializer owns argument identity. The cache owns lifetime (TTL plus a size bound). The caller owns invalidation triggers, expressed as either an explicit invalidate call or a should-invalidate predicate. With those boundaries the implementation falls out: read, decide freshness, compute on miss, evict if oversized.
Volunteer the dangerous cases. JSON serialization breaks for cycles, Map/Set, Dates, undefined, and deliberate identity (DOM nodes, class instances). Without an eviction bound a long-running app leaks memory through the cache. Without a TTL or invalidate hook a "fast" product page silently shows stale data after upstream entities change. Without a re-insertion trick the eviction throws away the freshest key first. State each tradeoff before the interviewer has to ask.
Reference Implementation: Memoize With TTL, Bounded Size, And Invalidation
A small public contract that lets the caller pick the eviction policy. The wrapped function is unchanged; everything else is composition.
type CacheEntry<R> = {
value: R;
storedAt: number;
};
type MemoizeOptions<A extends unknown[], R> = {
// Argument shape decides cache identity. Default uses JSON; override for
// arguments that are not JSON-stable (Date, Map, classes) or for arguments
// whose identity is the only meaningful key (e.g. DOM nodes by reference).
serializeArgs?: (args: A) => string;
// Eviction policy. Memoize is most useful when the cache is bounded:
// a per-call TTL handles freshness, max size handles unbounded keys.
ttlMs?: number;
maxSize?: number;
// Inversion of control: let the caller decide whether to invalidate based
// on (args, cachedValue). This is how product code expresses "expire when
// an upstream entity changes" without leaking cache details into the
// wrapped function.
shouldInvalidate?: (args: A, cached: R) => boolean;
};
function memoize<A extends unknown[], R>(
fn: (...args: A) => R,
options: MemoizeOptions<A, R> = {}
): ((...args: A) => R) & {
cache: Map<string, CacheEntry<R>>;
invalidate: (...args: A) => void;
clear: () => void;
} {
const serialize = options.serializeArgs ?? ((args: A) => JSON.stringify(args));
const cache = new Map<string, CacheEntry<R>>();
function get(args: A): R {
const key = serialize(args);
const existing = cache.get(key);
const now = Date.now();
const expired =
existing !== undefined &&
((options.ttlMs !== undefined && now - existing.storedAt >= options.ttlMs) ||
(options.shouldInvalidate?.(args, existing.value) ?? false));
if (existing !== undefined && !expired) {
// LRU-ish: re-insertion moves the key to the most recent slot in the
// Map iteration order, so eviction below removes the actually-oldest.
cache.delete(key);
cache.set(key, existing);
return existing.value;
}
const value = fn(...args);
cache.set(key, { value, storedAt: now });
if (options.maxSize !== undefined && cache.size > options.maxSize) {
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) cache.delete(oldestKey);
}
return value;
}
const memoized = ((...args: A) => get(args)) as ReturnType<typeof memoize<A, R>>;
memoized.cache = cache;
memoized.invalidate = (...args: A) => {
cache.delete(serialize(args));
};
memoized.clear = () => cache.clear();
return memoized;
}Runnable Playground
Edit the implementation and run the tests directly in the browser. For system design questions, the playground focuses on the core state/data logic that the UI would call.
type CacheEntry<R> = {
value: R;
storedAt: number;
};
type MemoizeOptions<A extends unknown[], R> = {
// Argument shape decides cache identity. Default uses JSON; override for
// arguments that are not JSON-stable (Date, Map, classes) or for arguments
// whose identity is the only meaningful key (e.g. DOM nodes by reference).
serializeArgs?: (args: A) => string;
// Eviction policy. Memoize is most useful when the cache is bounded:
// a per-call TTL handles freshness, max size handles unbounded keys.
ttlMs?: number;
maxSize?: number;
// Inversion of control: let the caller decide whether to invalidate based
// on (args, cachedValue). This is how product code expresses "expire when
// an upstream entity changes" without leaking cache details into the
// wrapped function.
shouldInvalidate?: (args: A, cached: R) => boolean;
};
function memoize<A extends unknown[], R>(
fn: (...args: A) => R,
options: MemoizeOptions<A, R> = {}
): ((...args: A) => R) & {
cache: Map<string, CacheEntry<R>>;
invalidate: (...args: A) => void;
clear: () => void;
} {
const serialize = options.serializeArgs ?? ((args: A) => JSON.stringify(args));
const cache = new Map<string, CacheEntry<R>>();
function get(args: A): R {
const key = serialize(args);
const existing = cache.get(key);
const now = Date.now();
const expired =
existing !== undefined &&
((options.ttlMs !== undefined && now - existing.storedAt >= options.ttlMs) ||
(options.shouldInvalidate?.(args, existing.value) ?? false));
if (existing !== undefined && !expired) {
// LRU-ish: re-insertion moves the key to the most recent slot in the
// Map iteration order, so eviction below removes the actually-oldest.
cache.delete(key);
cache.set(key, existing);
return existing.value;
}
const value = fn(...args);
cache.set(key, { value, storedAt: now });
if (options.maxSize !== undefined && cache.size > options.maxSize) {
const oldestKey = cache.keys().next().value;
if (oldestKey !== undefined) cache.delete(oldestKey);
}
return value;
}
const memoized = ((...args: A) => get(args)) as ReturnType<typeof memoize<A, R>>;
memoized.cache = cache;
memoized.invalidate = (...args: A) => {
cache.delete(serialize(args));
};
memoized.clear = () => cache.clear();
return memoized;
}
Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
describe('memoize', () => {
it('returns the cached value on identical arguments', () => {
const fn = vi.fn((a: number, b: number) => a + b);
const memoized = memoize(fn);
expect(memoized(1, 2)).toBe(3);
expect(memoized(1, 2)).toBe(3);
expect(fn).toHaveBeenCalledTimes(1);
});
it('respects ttl', () => {
vi.useFakeTimers();
const fn = vi.fn((value: string) => value.toUpperCase());
const memoized = memoize(fn, { ttlMs: 100 });
memoized('a');
memoized('a');
vi.advanceTimersByTime(150);
memoized('a');
expect(fn).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('evicts oldest when maxSize is exceeded', () => {
const memoized = memoize((value: string) => value, { maxSize: 2 });
memoized('a');
memoized('b');
memoized('c');
expect(memoized.cache.has('["a"]')).toBe(false);
expect(memoized.cache.has('["b"]')).toBe(true);
expect(memoized.cache.has('["c"]')).toBe(true);
});
it('exposes invalidate and clear', () => {
const fn = vi.fn((value: string) => value);
const memoized = memoize(fn);
memoized('a');
memoized.invalidate('a');
memoized('a');
expect(fn).toHaveBeenCalledTimes(2);
memoized.clear();
expect(memoized.cache.size).toBe(0);
});
});Interviewer Signal
Tests whether you can turn a familiar utility into a precise contract instead of coding only the happy path.
Constraints
- Define the function signature before coding.
- Do not rely on global mutable state unless it is part of the returned closure.
- Explain time and space cost for the common path.
Model Answer Shape
- Write the smallest public contract first.
- Cover empty input, repeated calls, thrown errors, and cleanup behavior.
- Keep implementation readable enough to narrate under interview pressure.
Tradeoffs
- A compact implementation is attractive, but explicit state names are easier to debug live.
- Supporting every possible input can distract from the core contract; state the scope before coding.
Edge Cases
- No arguments or undefined callbacks.
- Synchronous throw inside the wrapped function.
- Repeated calls before the previous result settles.
Testing And Proof
- Happy path with representative inputs.
- Boundary input and repeated invocation.
- Cleanup or cancellation if timers or promises are involved.
Follow-Ups
- How would you expose cancellation?
- How would the API change for React usage?