Introduction
ReadonlyView gives consumers a stable read surface while the owner keeps the ordinary mutable source. It is a membrane, not state management.
const view = readonlyView(source)
Ownership model
Keep the source private. Export the view. Mutate only through the source reference; existing views observe replacement, addition, deletion, collection changes, and Date changes.
Examples
Each example uses the public API and can be copied into a modern JavaScript project. TypeScript rejects the mutation lines before they reach runtime.
import { readonlyView } from '@nipe-solutions/readonly-view'
Nested protection
const source = { user: { name: 'Alice' } }
const view = readonlyView(source)
Reflect.set(view.user, 'name', 'Eve') // throws
Owner-side live updates
import { readonlyView } from '@nipe-solutions/readonly-view'
const source = { user: { name: 'Alice' } }
const view = readonlyView(source)
source.user.name = 'Bob'
console.log(view.user.name) // 'Bob'
Arrays
const source = { items: [{ id: 1 }] }
const view = readonlyView(source)
source.items.push({ id: 2 })
view.items.map(item => item.id) // [1, 2]
view.items.push({ id: 3 }) // throws
Map and Set
const source = {
map: new Map([['selected', { id: 1 }]]),
set: new Set([{ id: 1 }]),
}
const view = readonlyView(source)
view.map.get('selected')?.id // 1
view.set.clear() // throws
Date
const source = { updatedAt: new Date('2026-09-03') }
const view = readonlyView(source)
source.updatedAt.setUTCDate(4)
view.updatedAt.getUTCDate() // 4
view.updatedAt.setUTCDate(5) // throws
Circular references
const source = { label: 'root' }
source.self = source
const view = readonlyView(source)
view.self === view // true
Shared identity
const shared = { id: 1 }
const source = {
items: [shared],
map: new Map([['selected', shared]]),
set: new Set([shared]),
}
const view = readonlyView(source)
view.items[0] === view.map.get('selected') // true
[...view.set][0] === view.items[0] // true
Mutation rejection
import {
DirectMutationError,
readonlyView,
} from '@nipe-solutions/readonly-view'
const source = { item: { id: 1 } }
const view = readonlyView(source)
try {
Reflect.set(view.item, 'id', 2)
} catch (error) {
if (error instanceof DirectMutationError) {
console.log(error.name) // 'DirectMutationError'
} else {
throw error
}
}
Runtime guarantees
- Every write through a supported view throws.
- The source is never frozen, sealed, cloned, or intentionally changed.
- Nested values are wrapped lazily and cached by identity.
- Unsupported native values fail explicitly.
ReadonlyView is not a sandbox. Closures and getters can cause external side effects.
Supported types
| Value | Status | Semantics |
|---|---|---|
| Object, Array, Map, Set, Date | Full | Live and deeply protected |
| Function, custom class | Documented | Readonly receiver; private brands may reject |
| RegExp, Error, URL, buffers, typed arrays, weak collections, Promise | Unsupported | Throws instead of leaking mutation access |
Objects and arrays
Properties, symbols, getters, descriptors, indexes,
callbacks, spread, and iteration all pass exposed
values through the same membrane. Array identity is
preserved with Array.isArray(view).
Map and Set
Reads, keys, values, entries, iteration, size, and
forEach work with wrapped values. set,
add, delete, and
clear throw. A view key from the same
membrane remains usable for lookup.
Date
Native reads use a valid Date receiver. Every
available set* method is blocked. The
source Date remains mutable and live.
Functions and custom classes
User methods receive the readonly receiver, so
this.balance -= amount throws. A
function that closes over the source can still
mutate it. Private fields may fail their native
brand check because rebinding to the mutable source
would be unsafe. Prototype reflection is protected,
so class views intentionally do not preserve
instanceof.
Circular references and shared identity
Within one membrane, one source maps to one view—even across properties, arrays, Map, Set, and cycles. Separate top-level calls create independent membranes.
Symbols and property descriptors
Own keys, symbols, membership, enumeration, descriptors, accessors, spread, and destructuring behave live. Descriptor values are views, never raw nested sources.
Proxy invariants
Controlled extensible shadow targets avoid the non-configurable-property escape inherent in directly proxying arbitrary sources. View descriptors describe the virtual view while source descriptors remain untouched.
Compile-time guarantees
DeepReadonly<T> handles unions,
recursion, tuples, arrays, Map, Set, Date, symbols,
optionals, nullability, and callable types.
Performance
Initial creation wraps one value. Reads carry proxy overhead; collection adapters allocate protected iterator results. Size and methodology are checked in the repository without performance marketing claims.
Security and trust model
No evaluation, serialization, source writes, or eager getter calls. Consumer proxies and arbitrary user code remain trusted inputs. Mutable backing-memory types are rejected.
API reference
readonlyView(source)- Create a deeply readonly live view.
isReadonlyView(value)- Recognize package views.
DirectMutationError- Mutation metadata and safe message.
UnsupportedTypeError- Rejected type kind.
DeepReadonly<T>- Public recursive readonly type.
Migration from ImmuView v1
// v1
const state = readonly(data)
state.internalSet(next)
// v2
const source = data
const state = readonlyView(source)
source.count += 1
The package is now
@nipe-solutions/readonly-view. The
wrapper .value, validation, deep merge,
and mutation lifecycle are gone.
FAQ
Is this Object.freeze?
No. The source remains mutable and the view stays live.
Why reject types?
Explicit failure is safer than accidental native mutation access.
Contributing
Use Node 24 and run npm run check. Add
a failing regression test before each bug fix.