Skip to content

Latest commit

 

History

65 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ReadonlyView

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); // Bob

Install

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.

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 rejection

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.

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); // Alice
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 } };

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));

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

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.

Guarantees

  • 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.

Supported values

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.

When not to use it

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.

Performance

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.

API

  • readonlyView<T>(source: T): DeepReadonly<T> 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<T> models the runtime readonly contract recursively.

Documentation

Documentation site · Mental model · Use cases · Choosing an approach · Architecture · API · Supported types · Compatibility · Migration · Security · Contributing

License

MIT

Releases

Packages

Used by

Contributors

Languages