RReadonlyView NIPE primitives / 01

The source stays mutable.
The view does not.

A deeply readonly, lazy, live view over mutable JavaScript data. Runtime enforcement and TypeScript types, without cloning or freezing.

npm install @nipe-solutions/readonly-view
owner.tspublic.ts

Mutable source

source.user.name = 'Bob'

Readonly view

view.user.name = 'Eve'
view.user.name → 'Alice'
LiveOwner changes appear immediately.
LazyNested proxies exist only when read.
DeepEvery supported exposure route is wrapped.
v2Zero runtime dependencies. 1.9 kB minified + gzip baseline.

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.