immutableView
VIEW != SNAPSHOT. The original reference stays mutable. If you retain a direct reference to the source object, you can still mutate it — the view only blocks mutation through the proxy. Use
snapshotfor a fully independent copy, orvault()for closure-sealed isolation.
What it does
immutableView wraps a value in a Proxy that intercepts every mutation operation and throws a TypeError. Nested objects are wrapped lazily on first property access, not upfront, so the O(1) wrap cost is paid once at the top level. The proxy is cached in a WeakMap so the same target always returns the identical proxy reference.
Primitive values (number, string, boolean, null, undefined, symbol, bigint) pass through unchanged — only object values get a proxy.
All 13 Proxy traps are handled:
set/deleteProperty/defineProperty/setPrototypeOf/preventExtensions— throwTypeErrorunconditionally.get— returns proxied nested objects for freezable values; returns the raw value for primitives. Mutator methods on built-in collection types (Map,Set,WeakMap,WeakSet,Array,Date) are replaced with throwing stubs.apply— validates the call-site receiver (V1): rejects any mutable object receiver that is neither the original target nor another immutable view.construct— throwsTypeErrorunconditionally; an immutable view of a constructor must not manufacture mutable instances (V1).getPrototypeOf/has/ownKeys/getOwnPropertyDescriptor/isExtensible— forward to the target through cachedReflect.*bindings (V2), wrapping any freezable values before returning.
When to use
- You want to hand a read-only reference to a shared data structure without cloning it.
- You need the consumer to see live updates through the view (the underlying data can change; the view reflects the current state).
- You are wrapping a function or constructor and need to ensure callers cannot misuse it with a mutable receiver.
- You want to protect against accidental mutation in code that receives a config or state object it should not own.
When not to use
- You need a completely independent copy that cannot be affected by changes to the original — use
snapshot(deep clone + freeze). - You need runtime immutability baked into the object itself (no proxy) — use
deepFreeze. - You need collection-specific read-only semantics with predictable snapshot-at-construction isolation — use
immutableMapVieworimmutableSetView. - The value contains non-cloneable internal slots where a proxy alone is insufficient and the view needs to block methods via class internals.
Guarantees
- All structural mutations throw. Property set, delete, define, prototype reassignment, and
preventExtensionsall throwTypeErrorthrough the proxy. - Nested objects are wrapped transitively. Accessing a property that holds an object returns another proxy; mutation on that nested proxy also throws.
- Proxy invariant respected. For
non-writable + non-configurableown property descriptors, thegettrap returns the exact original value (required by the ECMAScript Proxy invariant §10.5.8). The wrapped value is not re-wrapped in that case. - Collection mutators blocked.
Map.set/delete/clear,Set.add/delete/clear,WeakMap.set/delete,WeakSet.add/delete,Array.push/pop/shift/unshift/splice/sort/reverse/fill/copyWithin, and allDate.set*methods are replaced with throwing stubs. - Subclass mutators denied by default (V3). For objects with internal slots (
Map,Set,WeakMap,WeakSet,Date), any function-typed property not on the explicit read-method allow-list is blocked. This closes the bypass whereclass Evil extends Map { sneakSet(k,v){this.set(k,v)} }would previously call through to the raw target. - Apply trap requires safe receiver (V1). When calling a wrapped function, the
thisargument must benull,undefined, the original target, or another immutable view proxy. Any other object receiver causes an immediateTypeErrorto preventevil.call(target)mutations. - Construct trap blocks
new view(...)(V1). Callingnewon an immutable view of a constructor always throws. A view should not manufacture mutable instances. toJSONsuppression opt-in (V5). By defaultJSON.stringifycalls the target'stoJSON()directly, bypassing the proxy traps. An attacker-suppliedtoJSONcould forge the serialized output. Pass{ blockToJSON: true }to make thegettrap returnundefinedfor thetoJSONkey, forcingJSON.stringifyto walk own enumerable properties through the proxy instead.- Cached builtins (V2). All
Reflect.*calls inside the proxy handler use module-level cached references captured at import time. Post-import poisoning ofReflect.getetc. does not affect the view.
Limitations
- VIEW, not snapshot (V8). Retaining the original reference allows mutation through it. The view reflects those changes immediately. This is by design for live-reference use cases; it is a hazard when you intended isolation.
blockToJSONis opt-in. Without it,JSON.stringify(view)invokestoJSON()on the real target. This is a documented backward-compatible default (V5).- Symbol-keyed mutator methods not blocked (V4).
getBlockedMutatoronly inspects string-keyed properties. A hypothetical future spec method named with a Symbol would not be caught by the deny list. No current built-in mutator uses a Symbol key. - Subclass deny-by-default covers Map/Set/WeakMap/WeakSet/Date only (V3). Custom subclasses of plain objects or
Arrayare not subject to the allow-list check — only types with internal slots trigger the deny-by-default path. - Iterator objects themselves are not protected (V7).
view.values()returns a generator. The generator's.nextproperty is writable — an attacker who holds the iterator reference can overwrite.nextto forge yielded values. The values yielded by an unmodified iterator are correctly wrapped proxies. - Accessor getters can return fresh mutable objects. The
gettrap wraps the returned value in a proxy, but a getter that returns a new object on each call will return a new proxy each time — the original returned object is not frozen. - Non-cloneable values are not an issue —
immutableViewnever clones; it only wraps. Functions, Symbols, DOM nodes, and other non-structured-cloneable values work fine.
Example
Basic object protection:
import { immutableView } from 'constancy';
const config = { db: { host: 'localhost', port: 5432 } };
const view = immutableView(config);
view.db.port = 9999; // TypeError: Cannot set property "port": object is immutable
view.db.host; // 'localhost' — reads work
// Original still mutable through direct reference (V8):
config.db.port = 9999;
console.log(view.db.port); // 9999 — view reflects the changeMap/Set through immutableView:
const m = new Map([['role', 'viewer']]);
const view = immutableView(m);
view.get('role'); // 'viewer'
view.set('role', 'admin'); // TypeError: Cannot set: object is immutable
// Subclass mutator denied by default (V3):
class ExtMap extends Map {
sneakSet(k: string, v: string) { this.set(k, v); }
}
const em = immutableView(new ExtMap([['k', 'safe']]));
(em as any).sneakSet('k', 'pwned'); // TypeError: Cannot invoke subclass method "sneakSet": object is immutableblockToJSON — V5 mitigation:
const obj = {
safe: 1,
toJSON() { return { safe: 'FORGED', secret: 'EXFIL' }; },
};
// Default: toJSON() is called — proxy traps never fire for it
const defaultView = immutableView(obj);
JSON.stringify(defaultView); // '{"safe":"FORGED","secret":"EXFIL"}'
// With blockToJSON: toJSON hidden, default serialization used
const safeView = immutableView(obj, { blockToJSON: true });
JSON.stringify(safeView); // '{"safe":1}'Apply/construct traps — V1 mitigation:
function greet(this: { name: string }) { return `Hello, ${this.name}`; }
const viewFn = immutableView(greet);
// Safe receiver (another immutable view) — allowed
const person = immutableView({ name: 'Alice' });
viewFn.call(person); // 'Hello, Alice'
// Mutable receiver — rejected
viewFn.call({ name: 'Bob' }); // TypeError: Cannot apply function with a mutable receiver: object is immutable
class Widget {}
const ViewWidget = immutableView(Widget);
new (ViewWidget as any)(); // TypeError: Cannot construct from immutable view: object is immutableComparison with related APIs
immutableView | deepFreeze | snapshot | immutableMapView | |
|---|---|---|---|---|
| Mutates original? | No (Proxy wrap) | Yes (freezes it) | No (clones first) | No (copies entries) |
| Severs reference? | No | No | Yes | Yes (at construction) |
| Sees source updates? | Yes | N/A (frozen) | No | No |
| Map/Set slot blocking | Yes (via Proxy traps) | No | No | Yes (class methods absent) |
| Subclass mutator deny | Yes (allow-list V3) | No | N/A | N/A |
| Wrap cost | O(1) | O(n) | O(n) clone | O(n) copy entries |
toJSON bypass | Opt-in block (V5) | N/A | N/A | N/A |
Common mistakes
- "I passed the view to a library — the library mutated the original data." A library that retains the original reference (not the view) can still mutate.
immutableViewonly protects the view reference. If you need full isolation, usesnapshot. - "My
class Evil extends Mapmethod got through before v3.0.1." The V3 fix adds deny-by-default for function-typed properties on slotted types. Upgrade to v3.0.1; then any function prop not in the read-method allow-list is blocked. - "
JSON.stringify(view)returned forged data." The target'stoJSON()method is called byJSON.stringifybefore the Proxy get trap fires. UseimmutableView(obj, { blockToJSON: true })to suppress it (V5). - "I wrapped a constructor and called
new view()— why no instance?" The construct trap throws unconditionally. Wrapping a constructor does not give you a safe factory; it gives you a view that refuses to construct. - "I see the nested value changed after I got a reference to it." The view wraps lazily — each property access returns a proxy of the current property value. Wrapping is not a snapshot.
Type signature
interface ImmutableViewOptions {
readonly blockToJSON?: boolean;
}
function immutableView<T>(
val: T,
options?: ImmutableViewOptions,
): T extends object ? DeepReadonly<T> : TDeepReadonly<T> recursively marks every nested object property as readonly in the TypeScript type system, mirroring the Proxy-enforced runtime behavior. Primitives return as T unchanged (no proxy is created).
isImmutableView(val) — returns true if val is a Proxy created by immutableView. Uses a private WeakSet registry; unforgeable from outside the module.
assertImmutableView(val, label?) — throws TypeError if val is not an immutable view proxy. label is prepended to the error message.
See also
immutableMapView— dedicated read-only Map with defensive copy at constructionimmutableSetView— dedicated read-only Set with defensive copy at constructiondeepFreeze— mutates the original by freezing every reachable nodesnapshot— deep clone + deepFreeze; severs the original reference- Security Audit — V1, V2, V3, V5 vectors and fix history