Expose live internal data without exposing mutation. ReadonlyView on npm is a lazy, live, deeply readonly runtime membrane for TypeScript; it is not a state manager, clone, frozen snapshot, or mutation API. Read the documentation or browse the source on GitHub.
import {
DirectMutationError,
readonlyView,
} from '@nipe-solutions/readonly-view';
function rejectsDirectMutation(action: () => void) {
try {
action();
} catch (error) {
if (error instanceof DirectMutationError) return;
throw error;
}
throw new Error('Expected DirectMutationError');
}
const source = { user: { name: 'Alice', roles: ['admin'] } };
const view = readonlyView(source);
rejectsDirectMutation(() => {
// @ts-expect-error readonly view
view.user.name = 'Eve';
});
source.user.name = 'Bob';
console.log(view.user.name); // Bobnpm install @nipe-solutions/readonly-view
pnpm add @nipe-solutions/readonly-view
yarn add @nipe-solutions/readonly-view
bun add @nipe-solutions/readonly-viewRequires Node.js 22 or 24, or a current evergreen browser.
SDKs often own mutable connection state but should not let consumers rewrite it through the public view. Keep the state private and publish a view; it gives consumers no new mutation path.
import {
DirectMutationError,
readonlyView,
} from '@nipe-solutions/readonly-view';
function rejectsDirectMutation(action: () => void) {
try {
action();
} catch (error) {
if (error instanceof DirectMutationError) return;
throw error;
}
throw new Error('Expected DirectMutationError');
}
class Client {
#state = { connected: false, user: null as { name: string } | null };
readonly state = readonlyView(this.#state);
connect(user: { name: string }) {
this.#state.connected = true;
this.#state.user = user;
}
}
const client = new Client();
const state = client.state;
client.connect({ name: 'Alice' });
rejectsDirectMutation(() => {
// @ts-expect-error readonly SDK state
state.user!.name = 'Eve';
});
console.log(state.connected); // true: the view stays live after rejectionReadonlyView only removes mutation capability from the view. The owner must still control other mutable aliases: connect(user) above preserves the supplied object reference, so a consumer that retains user can still mutate it. Copy or normalize inputs when that is not acceptable.
ReadonlyView is a strong fit when these five boundary conditions apply:
- One library, SDK, host, or subsystem clearly owns a mutable source graph.
- Consumers need one stable reference that reflects later owner updates.
- Consumers should inspect that graph, while writes through the published reference must fail at runtime and in TypeScript.
- The public graph primarily contains values in ReadonlyView’s documented support matrix.
- The owner can keep the source and other mutable aliases private or otherwise controlled.
Typical boundaries include an SDK publishing public state, a registry exposing its entries, or a host giving plugins context.
import {
DirectMutationError,
readonlyView,
} from '@nipe-solutions/readonly-view';
function rejectsDirectMutation(action: () => void) {
try {
action();
} catch (error) {
if (error instanceof DirectMutationError) return;
throw error;
}
throw new Error('Expected DirectMutationError');
}
class Registry {
#entries = new Map<string, { name: string }>();
readonly entries = readonlyView(this.#entries);
register(key: string, value: { name: string }) {
this.#entries.set(key, value);
}
}
const registry = new Registry();
registry.register('primary', { name: 'Alice' });
rejectsDirectMutation(() => {
// @ts-expect-error readonly registry Map
registry.entries.set('next', { name: 'Eve' });
});
console.log(registry.entries.get('primary')?.name); // Aliceimport {
DirectMutationError,
readonlyView,
type DeepReadonly,
} from '@nipe-solutions/readonly-view';
function rejectsDirectMutation(action: () => void) {
try {
action();
} catch (error) {
if (error instanceof DirectMutationError) return;
throw error;
}
throw new Error('Expected DirectMutationError');
}
type PluginContext = { configuration: { retries: number } };
function initialize(context: DeepReadonly<PluginContext>) {
console.log(context.configuration.retries);
rejectsDirectMutation(() => {
// @ts-expect-error readonly nested plugin configuration
context.configuration.retries = 5;
});
console.log(context.configuration.retries); // 3
}
const source: PluginContext = { configuration: { retries: 3 } };
initialize(readonlyView(source));| Capability | TypeScript readonly |
Object.freeze |
Deep freeze | Snapshot / Immer | ReadonlyView |
|---|---|---|---|---|---|
| Compile-time protection | Yes, where typed | No | No | Optional types | Yes |
| Runtime depth | None | Shallow | Deep | Snapshot/draft-specific | Deep |
| Owner retains mutation | Yes | Top-level no; nested yes | No | Yes, on original | Yes |
| Live owner updates | Yes | Nested updates remain live | N/A: owner is frozen | No: new snapshot/state | Yes |
| Traversal/copying | None | None | Eager traversal | Produces/copies state | Lazy, on access |
| New-state production | No | No | No | Yes | No |
These tools solve different problems. Immer produces new state through convenient mutations; ReadonlyView exposes existing owner-controlled data without granting mutation through the view.
Owner code ──mutable reference──▶ Source object graph
│
readonlyView()
│
Consumer code ◀──readonly access── Readonly membrane
source is owned by the library, host, or state container. readonlyView(source) gives a consumer a separate capability: read the same live graph. Owner writes are visible through the view; any write through the view is rejected. It does not make the source immutable or revoke mutable aliases the consumer already holds.
- Writes through the view throw
DirectMutationError. - Supported nested values are protected lazily.
- The source is never intentionally changed, frozen, sealed, or eagerly traversed.
- Owner-side changes remain visible.
- Shared references and cycles preserve identity inside one membrane.
- Unsupported built-ins throw
UnsupportedTypeError.
See guarantees and non-guarantees.
Fully supported: primitives, plain/null-prototype objects, arrays/tuples, Map, Set, Date, symbols, accessors, shared references, and cycles. Functions and custom classes have documented receiver/private-field semantics. Mutable native buffers, typed arrays, weak collections, Promise, RegExp, Error, URL, and URLSearchParams are rejected. See the support matrix.
Do not use ReadonlyView when you need immutable snapshots, structural sharing with new-state production, or a mechanism to prevent the owner from mutating data. ReadonlyView is not a security sandbox for hostile code; use process, realm, worker, permission, or protocol isolation for that threat model. Use a snapshot, persistent data structure, deep freeze, state-management tool, or isolation boundary designed for the actual job instead.
Initial wrapping creates one proxy and does not walk the graph. Nested proxies are created on access and reused through WeakMaps. Proxy reads still have overhead. See performance and benchmarks.
readonlyView<T>(source: T): DeepReadonly<T>creates an independent membrane. Primitives return unchanged; an existing view is returned unchanged.isReadonlyView(value: unknown): booleanrecognizes ReadonlyView proxies.DirectMutationErrorexposesoperation, optionalproperty, andobjectKind.UnsupportedTypeErrorexposeskind.DeepReadonly<T>models the runtime readonly contract recursively.
Documentation site · Mental model · Use cases · Choosing an approach · Architecture · API · Supported types · Compatibility · Migration · Security · Contributing
MIT