An SDK can own a mutable state object and still need to expose it to consumers. The difficult part is deciding what a consumer actually receives.
A copy can become stale. Freezing the SDK's original state prevents the owner from changing it. Returning the original object with a readonly TypeScript type relies on callers respecting a compile-time contract.
For one particular use case, I want a live view: callers can read supported nested values, the SDK can keep updating its source, and writes through that view are rejected at runtime.
The examples here follow the 2.0 alpha API documented in the repository.
That is the boundary I built ReadonlyView for.
The outer object is only the outer object
Here is a small example of what Object.freeze protects:
const state = Object.freeze({
user: { name: "Alice" },
});
console.log(Object.isFrozen(state)); // true
console.log(Object.isFrozen(state.user)); // false
state.user.name = "Bob";
console.log(state.user.name); // Bob
This is expected behavior: freezing is shallow. Freezing every reachable supported object would require a deeper operation, and it would still prevent the owner from updating that same graph.
That can be exactly what you want for an immutable snapshot. It is awkward when the SDK needs to retain an internally mutable source.
Separate the public view from the owner's reference
ReadonlyView wraps the source and returns a deeply readonly view for supported values:
import {
readonlyView,
DirectMutationError,
} from "@nipe-solutions/readonly-view";
const source = {
user: { name: "Alice", roles: ["editor"] },
};
const view = readonlyView(source);
source.user.name = "Bob";
console.log(view.user.name); // Bob
try {
view.user.name = "Carol";
} catch (error) {
console.log(error instanceof DirectMutationError); // true
}
console.log(source.user.name); // Bob
This example uses JavaScript so you can see the runtime rejection. In TypeScript, the returned type also makes that assignment a compile-time error.
The original source stays mutable for the owner. A write through the public view throws. Owner-side changes remain visible through that same view.
You could use this boundary for SDK state, a registry or a plugin context, provided the data fits the supported-value contract.
A graph doesn't need to be copied up front
The library creates nested proxies on access and reuses them. Creating the initial view doesn't eagerly walk the entire object graph.
Shared references and cycles preserve identity within one view. For example, two source paths pointing to the same object still point to the same wrapped object through that view.
Lazy wrapping avoids an initial deep traversal; proxy reads still have overhead. I don't want to turn that design choice into a blanket performance claim. The benchmark methodology explains how performance is assessed.
The owner still has responsibilities
A readonly view doesn't revoke other mutable references. If the owner hands out the original object elsewhere, that alias can still change it.
The supported-type boundary matters too. Plain objects, arrays, Maps, Sets and Dates have documented support. Some built-ins, including mutable native buffers and weak collections, are rejected. Functions and custom classes have receiver semantics worth reading before adoption.
Use the support matrix and guarantees to check the values your API exposes. This is a runtime readonly boundary, not a security sandbox for untrusted code.
It also doesn't manage application state or subscribe React to updates. A live backing value and a UI rerender are separate concerns.
A small adoption experiment
The package is @nipe-solutions/readonly-view. Start with one public state object whose mutable owner you can identify.
Then check three things:
- Owner-side updates appear through the view.
- Writes through supported nested paths are rejected.
- Consumers don't receive an unintended mutable alias somewhere else.
That is a useful trial without replacing your state model.
I maintain this library and would appreciate reports from real SDKs, registries or plugin interfaces. A small example of an unsupported value, unexpected receiver behavior or confusing readonly boundary would be especially helpful in GitHub Issues.
When you expose internal state, do you need a snapshot, a live readonly view, or explicit getter methods?
ReadonlyView is part of my NIPE open-source frontend libraries. I'm looking for real usage and concrete feedback to improve them.
Top comments (0)