Memoizing Expensive Synchronous Validators to Avoid Redundant Work
Some synchronous rules are cheap, but others — a Luhn checksum over a card number, a heavy regular expression, a password-strength scorer — are expensive enough that re-running them on every input event wastes real CPU; this recipe wraps such a validator in a memoization cache keyed by its input so the Constraint Validation API still sees a fresh result while the work happens at most once per distinct value.
When to Use This Memoization Approach
Reach for memoization only when the validator is pure (its output depends solely on its input) and measurably expensive. Memoizing a trivial required check adds a Map lookup and buys nothing. This is a performance refinement layered on top of Composing Pure Validator Functions: compose first, then cache the one rule that shows up in a profile.
Pick this approach when:
- The rule is CPU-bound and synchronous — anything network-backed belongs in asynchronous server checks, which have their own caching and cancellation story.
- The same value is validated repeatedly, e.g. a field re-validated on
input, onblur, and again on submit. - The validator is deterministic and side-effect free; if it reads
Date.now()or a mutable global, a cache will serve stale answers.
If your rules are declared as a Zod schema validation, memoize at the safeParse boundary rather than inside individual refinements.
A Map-Backed memoize Wrapper Over a Pure Validator
The wrapper takes any pure Validator<string> and returns a validator with the same signature, so it drops straight into a composed pipeline and the novalidate + Constraint Validation baseline is untouched. A Map keyed by the raw input value stores the last computed message (or null for pass).
// A pure rule: input in, message-or-null out. No DOM, no I/O.
type Validator<T> = (value: T) => string | null;
// Wrap a pure validator so each distinct input is computed at most once.
function memoize(
fn: Validator<string>,
{ maxSize = 100 }: { maxSize?: number } = {},
): Validator<string> {
const cache = new Map<string, string | null>();
return (value: string): string | null => {
if (cache.has(value)) {
return cache.get(value)!; // cache hit — no recomputation
}
const result = fn(value); // cache miss — run the expensive rule
// Bound the cache: evict the oldest entry (Map preserves insertion order).
if (cache.size >= maxSize) {
cache.delete(cache.keys().next().value as string);
}
cache.set(value, result);
return result;
};
}
// An intentionally expensive, deterministic rule (Luhn checksum).
const luhnValid: Validator<string> = (value) => {
const digits = value.replace(/\s+/g, '');
if (!/^\d{12,19}$/.test(digits)) return 'Enter a valid card number.';
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
let d = digits.charCodeAt(i) - 48;
if (alt) { d *= 2; if (d > 9) d -= 9; }
sum += d;
alt = !alt;
}
return sum % 10 === 0 ? null : 'This card number is not valid.';
};
const validateCard = memoize(luhnValid, { maxSize: 50 });
// Thin impure adapter — the ONLY place that touches the DOM.
const form = document.querySelector<HTMLFormElement>('#checkout')!; // <form id="checkout" novalidate>
const card = form.querySelector<HTMLInputElement>('#card')!;
card.addEventListener('input', () => {
const error = validateCard(card.value); // repeated values are free
card.setCustomValidity(error ?? '');
card.setAttribute('aria-invalid', String(error !== null));
});
form.addEventListener('submit', (event) => {
// Same call the input handler made — served from cache, not recomputed.
card.setCustomValidity(validateCard(card.value) ?? '');
if (!form.reportValidity()) event.preventDefault();
});
Because the memoized function preserves the Validator<string> type, you can also feed it into compose([...]) from the composition recipe and only the expensive rule pays the cache cost.
memoize Options Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
fn |
Validator<string> |
— | The pure, deterministic rule to wrap; must depend only on its input. |
maxSize |
number |
100 |
Upper bound on cached entries; the oldest is evicted first (FIFO). |
| cache key | string (raw value) |
— | Identity of a computation; distinct strings are distinct cache slots. |
| cached value | string | null |
— | Stored result — null means the value passed, a string is the message. |
| return type | Validator<string> |
— | Same signature as the input, so it composes and adapts identically. |
Verification Steps
import { expect, test, vi } from 'vitest';
test('memoize runs the expensive rule once per distinct value', () => {
const spy = vi.fn<Validator<string>>((v) => (v === 'ok' ? null : 'bad'));
const cached = memoize(spy, { maxSize: 10 });
expect(cached('ok')).toBeNull();
expect(cached('ok')).toBeNull(); // served from cache
expect(cached('nope')).toBe('bad');
expect(spy).toHaveBeenCalledTimes(2); // 'ok' + 'nope', not 3
});
Edge Cases & Failure Modes
Non-deterministic validators serving stale results. If the wrapped rule reads anything beyond its argument — the clock, a locale, a mutable config — the cache will return a value that was correct only at insertion time. Keep the function referentially transparent; inject dynamic inputs (like a cut-off date) as part of the key, e.g. memoize over ${value}|${today}, so a changed dependency becomes a different cache slot rather than a stale hit.
Unbounded growth on high-cardinality fields. A field that sees thousands of unique values (a free-text search, a paste-heavy input) will fill an uncapped Map and leak memory for the page’s lifetime. Always set a maxSize appropriate to the field and rely on the FIFO eviction above; for reference-identity keys prefer a WeakMap, which cannot be size-bounded but is collected with its keys.
Whitespace and normalization splitting the cache. "4111 1111" and "4111 1111" are different strings and therefore different cache keys, silently doubling the work you meant to save. Normalize the value (trim, collapse whitespace, lowercase) before it reaches the memoized function so equivalent inputs collapse to one entry — and keep that normalization consistent with your custom validity messages so users see the message that matches what they typed.
Choosing a Cache Key That Matches the Rule’s Real Inputs
The single most consequential decision in a memoized validator is what the key
represents. The FIFO Map above keys on the raw string, which is correct only when
the rule’s output truly depends on nothing else. The moment a rule also consults a
second value — a selected country for a postal-code pattern, a chosen currency for an
amount, a feature flag that toggles strictness — the raw value is no longer a complete
identity, and a bare value key will serve an answer computed under different
conditions. The fix is a composite key that folds every input the rule reads into one
deterministic string, so any change to a dependency lands in a fresh cache slot instead
of masquerading as a hit.
// A rule whose result depends on the value AND a second, changing input.
type CardContext = { readonly acceptedNetworks: readonly string[] };
// Build a stable key from every input the rule actually reads. Order matters:
// keep the field order fixed so equal inputs always serialize identically.
function keyOf(value: string, ctx: CardContext): string {
return `${value}�${[...ctx.acceptedNetworks].sort().join(',')}`;
}
function memoizeKeyed(
fn: (value: string, ctx: CardContext) => string | null,
{ maxSize = 100 }: { maxSize?: number } = {},
): (value: string, ctx: CardContext) => string | null {
const cache = new Map<string, string | null>();
return (value, ctx) => {
const key = keyOf(value, ctx); // identity = value + every dependency
if (cache.has(key)) return cache.get(key)!;
const result = fn(value, ctx);
if (cache.size >= maxSize) cache.delete(cache.keys().next().value as string);
cache.set(key, result);
return result;
};
}
The � separator is not decorative: a NUL byte cannot occur in ordinary form
input, so it guarantees that ("41", "11") and ("4", "111") never collide into the
same key — a real hazard with a naïve `${a}${b}` join. The same discipline applies
to normalization. Do the trimming, whitespace-collapsing, and case-folding before the
key is built, never inside the wrapped rule, so that the normalized form is what both the
cache and your message logic agree on.
Invalidating the Cache When Validation Rules Change
A memoized validator is a snapshot of a rule’s behaviour, and long-lived
single-page applications can outlive that snapshot. If a user switches locale, an admin
toggles a stricter password policy, or a lazy-loaded module swaps the checksum
implementation, every entry computed under the old rule is now wrong yet indistinguishable
from a correct hit. Two strategies keep this honest. The coarse one is to discard the
whole cache — expose a clear() method and call it on the config-change event. The
precise one is to version the rule and fold that version into the key, so stale entries
simply stop being reachable and age out through FIFO eviction without a manual sweep.
function memoizeVersioned(fn: Validator<string>) {
let version = 0;
const cache = new Map<string, string | null>();
const run = (value: string): string | null => {
const key = `${version}�${value}`; // version participates in identity
if (cache.has(key)) return cache.get(key)!;
const result = fn(value);
cache.set(key, result);
return result;
};
// Bump the version to abandon every prior result without touching the Map.
run.invalidate = () => { version += 1; };
return run;
}
Prefer versioning over clear() when a rule change can race with an in-flight
validation: bumping the version is atomic from the caller’s perspective, whereas clearing
mid-pass can drop an entry another handler is about to read. Either way, treat cache
lifetime as part of the rule’s contract, documented alongside the purity assumption the
whole pattern rests on.
Frequently Asked Questions
Is memoizing a synchronous validator ever premature optimization?
Usually, yes. A cache only pays off when the rule is measurably expensive and the same values recur. Profile first: if a validator does not appear in a flame chart or a performance.now() delta, wrapping it in a Map just adds indirection. Reserve this for checksum, heavy-regex, or scoring rules that fire on every keystroke.
Should I use memoization or debouncing to reduce validation work?
They solve different problems and compose well together. Debouncing reduces how often a rule runs by waiting for a pause in typing; memoization reduces how often it recomputes for values it has already seen. Debounce the event handler for cadence, and memoize the validator so a re-check of an unchanged value — on blur or submit — is free.
Can I share one memoized validator across several input fields?
Yes, and you should when the rule is identical — the cache is keyed by value, not by element, so two fields entering the same string share the stored result. This is only safe because the validator is pure and depends on nothing about the specific <input>. If a rule depends on another field, that is cross-field validation and needs a composite key, not a per-field cache.
Related Guides
- Composing Pure Validator Functions — the pipeline this memoized rule slots into.
- Synchronous Validation Patterns — the parent pattern and its DOM-free philosophy.
- Cancelling Stale Requests with AbortController — the async counterpart to caching, for network-backed rules.
- Schema-Based Validation with Zod — where to memoize at the
safeParseboundary instead of per-rule.