Expose a read-only Map or Set to callers
The problem
Your cache module maintains an internal Map<string, User> and exposes it to callers. Any caller with a reference to the Map can call .set() or .delete() and corrupt the cache — even though the Map object itself might be frozen at the shell level. You need a type-safe, runtime-enforced read-only view of the Map that blocks all mutation, including subclass-based bypasses.
Requirements
.set(),.delete(), and.clear()must be absent — not just blocked, absent.- TypeScript must reflect the constraint at compile time (
ReadonlyMap<K, V>). - Subclass methods that delegate to
.set()internally must not reach the internal slots. - Source isolation: changes to the internal Map after exposure must not affect the view.
Approach
Use immutableMapView (or immutableSetView for Set) rather than immutableView(map). Both dedicated helpers copy entries with structuredClone at construction time, so the returned view is a snapshot isolated from the source. Mutation methods (set, delete, clear) are simply absent from ImmutableMap.prototype — (view as any).set is undefined — which satisfies the TypeScript ReadonlyMap<K, V> return type at compile time and throws TypeError at runtime if called.
immutableView(map) is a Proxy over the original — it blocks .set() through a trap but still reflects live source changes (V8). It does not sever the reference. For a stable exported snapshot, the dedicated helpers are the right choice.
Implementation
import { immutableMapView, immutableSetView } from 'constancy';
// Internal mutable state
const _userCache = new Map<string, { name: string; role: string }>();
const _permissionCache = new Set<string>();
// Public read-only accessors — snapshot at call time
export function getUserCache(): ReadonlyMap<string, { name: string; role: string }> {
return immutableMapView(_userCache);
}
export function getPermissions(): ReadonlySet<string> {
return immutableSetView(_permissionCache);
}
// Callers:
const users = getUserCache();
users.get('alice'); // { name: 'Alice', role: 'admin' }
(users as any).set('x'); // TypeError: undefined is not a functionTradeoffs
immutableMapViewusesstructuredCloneat construction — non-cloneable values (functions, DOM nodes, Symbols as values) throwDataCloneErrorat wrap time (C4). Pre-validate Map contents before exposing.- The view is a snapshot. Callers who hold a reference to a previously-returned view see the Map state at the time they called
getUserCache(), not the current state. Call the accessor again for a fresh snapshot. has(originalObj)returnsfalsewhen the key was an object — the view holds a cloned reference, not the original (C2). Use primitive keys for reliablehas()calls.- Lazy freeze window (C1): values are cloned at construction but not frozen until first read. Code that bypasses the public API via private field access could mutate in this window — public API users are unaffected.
Alternatives considered
immutableView(map)— Proxy-based live view, no entry copy. Reflects source changes (V8 limitation). The V3 subclass-deny protection blocksclass Evil extends Map { sneakSet() { this.set(...) } }only for types with internal slots, and still requires that all non-allow-listed function-typed properties are blocked. The dedicated helper sidesteps this entirely by making mutator methods absent rather than blocked. Rejected when source isolation is required.deepFreeze(map)— freezes the Map object shell (no new own properties), but[[MapData]]internal slots bypassObject.freezeentirely —.set()still works (F2). Rejected: does not block the slot mutators.