From 6979968250b1ebff216aa94865099c7c3e667bf7 Mon Sep 17 00:00:00 2001 From: Nicholas Petrasek Date: Thu, 1 Oct 2026 05:59:46 +0200 Subject: [PATCH] docs: explain use cases and provide complete examples --- README.md | 223 +++++++++++++----------------------------------------- 1 file changed, 54 insertions(+), 169 deletions(-) diff --git a/README.md b/README.md index 64d62aa..7ed843a 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,21 @@ # ReadonlyView -Expose live internal data without exposing mutation. [ReadonlyView on npm](https://www.npmjs.com/package/@nipe-solutions/readonly-view) 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](https://readonly-view.nipesolutions.com) or browse the [source on GitHub](https://github.com/NIPE-Solutions/readonly-view). +Let consumers read your internal state while keeping writes with the code that owns it. ReadonlyView gives an SDK, registry, or application subsystem a live, deeply readonly view over a mutable JavaScript object graph. Owner updates remain visible through the same view; writes through that view fail in TypeScript and at runtime. -```ts -import { - DirectMutationError, - readonlyView, -} from '@nipe-solutions/readonly-view'; +Use it when consumers need current data and should call your methods to change it. The owner must keep the mutable source and any other mutable references under control. -function rejectsDirectMutation(action: () => void) { - try { - action(); - } catch (error) { - if (error instanceof DirectMutationError) return; - throw error; - } - throw new Error('Expected DirectMutationError'); -} +[Documentation](https://readonly-view.nipesolutions.com) · [npm](https://www.npmjs.com/package/@nipe-solutions/readonly-view) · [Choosing an approach](docs/choosing-an-approach.md) -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); // Bob -``` - -## Install +## A live view of owner-controlled state ```bash npm install @nipe-solutions/readonly-view -pnpm add @nipe-solutions/readonly-view -yarn add @nipe-solutions/readonly-view -bun add @nipe-solutions/readonly-view ``` -Requires Node.js 22 or 24, or a current evergreen browser. - -## Why this exists (SDK) - -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. +Requires Node.js 22 or 24, or a current evergreen browser. The package includes TypeScript declarations and ESM/CommonJS entry points. ```ts -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'); -} +import { readonlyView } from '@nipe-solutions/readonly-view'; class Client { #state = { connected: false, user: null as { name: string } | null }; @@ -66,161 +23,89 @@ class Client { connect(user: { name: string }) { this.#state.connected = true; - this.#state.user = user; + this.#state.user = { name: user.name }; } } 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 rejection +console.log(state.connected); // false +client.connect({ name: 'Alice' }); +console.log(state.connected, state.user?.name); // true, Alice ``` -ReadonlyView 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. - -## Use ReadonlyView when - -ReadonlyView is a strong fit when these five boundary conditions apply: - -1. One library, SDK, host, or subsystem clearly owns a mutable source graph. -2. Consumers need one stable reference that reflects later owner updates. -3. Consumers should inspect that graph, while writes through the published reference must fail at runtime and in TypeScript. -4. The public graph primarily contains values in ReadonlyView’s documented support matrix. -5. The owner can keep the source and other mutable aliases private or otherwise controlled. +The consumer keeps one `state` reference. `connect()` changes the private source, and the view reflects that change without a subscription or a copied snapshot. It does not trigger UI rerenders; notification and state-management behavior belong to your application. -Typical boundaries include an SDK publishing public state, a registry exposing its entries, or a host giving plugins context. +This example copies the incoming user's name into owner-controlled data. Assigning the supplied `user` object directly would retain a mutable alias: whoever kept that object could still change it. ReadonlyView cannot revoke references a consumer already has. Copy or normalize inputs when your ownership boundary requires it. -```ts -import { - DirectMutationError, - readonlyView, -} from '@nipe-solutions/readonly-view'; +## What happens when a consumer writes? -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(); - 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); // Alice -``` +The types reject a direct assignment. JavaScript consumers, casts, or suppressed type errors still encounter a runtime check: ```ts import { 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 } }; +const source = { settings: { retries: 3 } }; +const view = readonlyView(source); -function initialize(context: DeepReadonly) { - console.log(context.configuration.retries); - rejectsDirectMutation(() => { - // @ts-expect-error readonly nested plugin configuration - context.configuration.retries = 5; - }); - console.log(context.configuration.retries); // 3 +try { + // @ts-expect-error Deliberately demonstrate a rejected readonly write. + view.settings.retries = 5; +} catch (error) { + if (!(error instanceof DirectMutationError)) throw error; + console.log(error.operation, error.property); // set, retries } -const source: PluginContext = { configuration: { retries: 3 } }; -initialize(readonlyView(source)); -``` - -## Comparison - -| 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. - -## Ownership mental model - -```text -Owner code ──mutable reference──▶ Source object graph - │ - readonlyView() - │ -Consumer code ◀──readonly access── Readonly membrane +console.log(source.settings.retries); // 3: the write never reached the source. +source.settings.retries = 4; +console.log(view.settings.retries); // 4: owner writes remain allowed. ``` -`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. +Nested objects and supported collections are protected lazily. Array mutation, `Map.set()`, `Set.add()`, and Date setters also throw `DirectMutationError` when called through the view. Shared references and cycles preserve their identity within one view's membrane. -## Guarantees +## Choose it for the boundary you have -- 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`. +ReadonlyView fits an SDK publishing connection state, a registry exposing entries, or a host passing trusted plugins a read-only context. In each case there is one owner, consumers need a live reference, and writes should go through the owner's API. -See [guarantees and non-guarantees](docs/guarantees.md). +| Your requirement | Usually choose | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| Only compile-time guidance for trusted TypeScript callers | TypeScript `readonly` or a readonly type | +| Make the object itself stop accepting writes | `Object.freeze()` for shallow protection, or a suitable deep-freeze implementation | +| Capture a value at one point in time | A snapshot or clone | +| Produce new immutable state with structural sharing | Immer or persistent data structures | +| Keep owner mutation while rejecting consumer writes through a live view | ReadonlyView | -## Supported values +ReadonlyView is not a security sandbox for hostile JavaScript. Getters, functions, and supplied Proxy traps may execute arbitrary code or affect state outside the view. Use process, realm, worker, permission, or protocol isolation for that threat model. Read the [security and trust model](docs/security-and-trust.md) and [guarantees](docs/guarantees.md). -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](docs/supported-types.md). +## Supported data and limits -## When not to use it +Primitives, plain and null-prototype objects, arrays/tuples, Map, Set, Date, symbols, accessors, shared references, and cycles are supported. Functions and custom classes have specific receiver and private-field semantics; a class view does not preserve `instanceof`. -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. +Mutable buffers, DataView, typed arrays, weak collections, Promise, RegExp, Error, URL, and URLSearchParams are rejected with `UnsupportedTypeError`. An unsupported nested value fails when it is reached, rather than during an eager traversal. See the [support matrix](docs/supported-types.md) before exposing an existing graph. -## Performance +The source is not intentionally changed, frozen, sealed, or walked when you create a view. Wrapping starts with one proxy; nested proxies are created on access and cached with WeakMaps. Proxy reads still have overhead. The [performance guide](docs/performance.md) and [benchmark methodology](benchmarks/README.md) explain the tradeoffs. -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](docs/performance.md) and [benchmarks](benchmarks/README.md). +## Public API -## API +- `readonlyView(source: T): DeepReadonly` creates an independent membrane. Primitives and existing views are returned unchanged. +- `isReadonlyView(value: unknown): boolean` recognizes the package's views. +- `DirectMutationError` reports `operation`, optional `property`, and `objectKind`. +- `UnsupportedTypeError` reports the unsupported `kind`. +- `DeepReadonly` describes the recursive readonly contract in TypeScript. -- `readonlyView(source: T): DeepReadonly` creates an independent membrane. Primitives return unchanged; an existing view is returned unchanged. -- `isReadonlyView(value: unknown): boolean` recognizes ReadonlyView proxies. -- `DirectMutationError` exposes `operation`, optional `property`, and `objectKind`. -- `UnsupportedTypeError` exposes `kind`. -- `DeepReadonly` models the runtime readonly contract recursively. +The [API reference](docs/api.md), [mental model](docs/mental-model.md), [use cases](docs/use-cases.md), and [compatibility notes](docs/compatibility.md) explain the details. Upgrading from v1? Read the [v2 migration guide](docs/migration-v1-v2.md). -## Documentation +## Local development -[Documentation site](https://readonly-view.nipesolutions.com) · [Mental model](docs/mental-model.md) · [Use cases](docs/use-cases.md) · [Choosing an approach](docs/choosing-an-approach.md) · [Architecture](docs/architecture.md) · [API](docs/api.md) · [Supported types](docs/supported-types.md) · [Compatibility](docs/compatibility.md) · [Migration](docs/migration-v1-v2.md) · [Security](docs/security-and-trust.md) · [Contributing](CONTRIBUTING.md) +```bash +npm ci +npm run dev:website +``` -## License +`npm run check` runs formatting, lint, types, unit and type tests, bundle-size and packed-package checks, website verification, and a benchmark smoke run. See [Contributing](CONTRIBUTING.md) for browser testing and development conventions. -MIT +[MIT](LICENSE) © Nicholas Petrasek and ReadonlyView contributors · [NIPE Open Source](https://opensource.nipesolutions.com)