RReadonlyView
Primitives NIPE Open Source

The source stays mutable.
The view does not.

Expose live internal data without exposing mutation. ReadonlyView adds 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. ESM and CommonJS.

Why this exists

An SDK often owns mutable connection state while applications need to inspect that state without rewriting it. Keep the source private and publish a live readonly view.

import { readonlyView } from '@nipe-solutions/readonly-view'

class Client {
  #state = { connected: false }
  readonly state = readonlyView(this.#state)

  connect() {
    this.#state.connected = true
  }
}

const client = new Client()
const state = client.state
client.connect()

state.connected // true
Reflect.set(state, 'connected', false) // throws

ReadonlyView removes mutation capability from the view. The owner must still control any other mutable aliases.

Read the ownership mental model.

Built for public read surfaces

Use ReadonlyView at an ownership boundary where consumers need current data but should not gain a mutation path through the public view.

SDK public state

Connection state stays current after the SDK connects, disconnects, or receives new data. Applications can inspect it but cannot rewrite it through state.

Internal registry

The registry keeps adding entries through its own API. Search and rendering code sees the current Map without access to mutating collection methods.

Plugin context

A host can update source configuration while plugins receive a live context that rejects accidental nested configuration writes.

Explore complete use-case examples.

Useful in your project?

If ReadonlyView helps you share live data safely, a GitHub star helps other developers discover it. Explore more tools from NIPE Open Source for your next project.

Choose the right tool

Readonly and immutable tools solve different ownership and lifecycle problems. Match the tool to the behavior you need.

A compact guide to readonly and immutable approaches
Need Choose Behavior
Typed API guidance TypeScript readonly Compile-time protection with no runtime change.
Shallow runtime boundary Object.freeze Blocks top-level writes on the frozen object; nested values remain mutable.
Deeply immutable value Deep freeze Eagerly traverses the graph and also prevents owner mutation.
Historical or successor state Snapshot / Immer Preserves a point in time or produces new state.
Owner-controlled live graph ReadonlyView Adds lazy, deep protection through the view while owner updates stay live.

Read the detailed decision guide.

Is ReadonlyView a fit?

Good fit

  • One owner controls the mutable source.
  • Existing views must reflect later owner updates.
  • Public writes need TypeScript guidance and runtime rejection.
  • Lazy wrapping is preferable to eager traversal or copying.

Not a fit

  • You need a point-in-time snapshot.
  • You need structural sharing or new-state production.
  • You need to prevent the owner from mutating the source.
  • You need a security sandbox for hostile JavaScript or other mutable aliases.

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.