A normalized entity cache stores records in a flat table keyed by type and then by id, so every record lives in exactly one place and relationships are held as id references instead of nested copies. APIs love to hand you deeply nested JSON — a post with its author embedded, and that same author copied into ten other posts — which means one edit leaves ten stale duplicates behind. Flattening the data into per-type tables, the way Redux, Apollo, and RTK Query do under the hood, gives every record a single source of truth.
Build a factory normalizedEntityCache() that returns five methods over a flat store shaped like { [type]: { [id]: entity } }. Every write is immutable: it returns a new store object and a new bucket for the type it touches, while records you did not change keep their exact reference.
type Store = Record<string, Record<string, object>>;
function normalizedEntityCache(): {
setEntity(type: string, entity: { id: string | number }): Store;
getEntity(type: string, id: string | number): object | undefined;
updateEntity(type: string, id: string | number, patch: object): Store;
getAll(type: string): object[];
normalize(type: string, items: object[], relations: Record<string, string>): Store;
};
const cache = normalizedEntityCache();
cache.setEntity('users', { id: 1, name: 'Ada' });
cache.getEntity('users', 1); // { id: 1, name: 'Ada' }
cache.updateEntity('users', 1, { name: 'Ada L.' });
cache.getEntity('users', 1); // { id: 1, name: 'Ada L.' }
cache.getAll('users'); // [{ id: 1, name: 'Ada L.' }]
const post = { id: 10, title: 'Hello', author: { id: 1, name: 'Ada' } };
// Pull the nested author into its own bucket, leaving an id behind.
cache.normalize('posts', [post], { author: 'users' });
cache.getEntity('posts', 10); // { id: 10, title: 'Hello', author: 1 }
cache.getEntity('users', 1); // { id: 1, name: 'Ada' }
{ [type]: { [id]: entity } }; every entity carries an id.setEntity and updateEntity return a new store and a new bucket for the type they touch; entities you did not change keep their reference.updateEntity spreads patch over the existing entity; a missing id is a no-op that returns the same store.field in relations mapped to a related type, normalize moves the nested object into that type's bucket and leaves only its id on the parent.