exobj
v0.6.0
Published
Controlled object mutation with deep read-only proxies and change subscriptions.
Maintainers
Readme
ExObj
ExObj is a dependency-free JavaScript library that uses Proxy to enforce
controlled object mutation. Data can be read normally, while direct assignment,
deletion, property definition, nested mutation, and mutating array methods are
blocked. Changes must go through ExObj.set() and ExObj.unset().
Runtime support
| Environment | Format | Entry point |
| --- | --- | --- |
| Node.js | CommonJS | dist/exobj.cjs |
| Node.js | ESM | dist/exobj.esm.js |
| Browser | ESM / module | dist/exobj.esm.js |
| Browser | Classic script / global | dist/exobj.umd.js |
| Browser | AMD | dist/exobj.umd.js |
| Browser | UMD | dist/exobj.umd.js |
| Deno | Native ESM | src/index.js or dist/exobj.esm.js |
All builds are generated from the same runtime-neutral src/core.js. The core
does not import Node.js APIs and does not depend on a DOM, so behavior remains
consistent across runtimes.
Installation
Install the local package archive:
npm install ./exobj-0.5.0.tgzAfter publishing the package to a registry, it can be installed by its registered package name. Node.js 18 or newer is recommended.
Node.js
ESM:
import ExObj from 'exobj';
const my_obj = ExObj.create({ name: 'Foo' });CommonJS:
const ExObj = require('exobj');
const my_obj = ExObj.create({ name: 'Foo' });The package exports map automatically selects dist/exobj.esm.js for
import and dist/exobj.cjs for require.
Browser
ESM or module script
<script type="module">
import ExObj from './dist/exobj.esm.js';
const my_obj = ExObj.create({ name: 'Foo' });
ExObj.set(my_obj, 'version', '1.0');
</script>The ESM build has both default and named exports:
import ExObj, { ExObj as ex_obj_class } from './dist/exobj.esm.js';Classic script and global
<script src="./dist/exobj.umd.js"></script>
<script>
const my_obj = ExObj.create({ name: 'Foo' });
ExObj.set(my_obj, 'version', '1.0');
</script>The UMD build exposes the class as globalThis.ExObj when no module loader is
present.
AMD
require(['./dist/exobj.umd'], function (ExObj) {
const my_obj = ExObj.create({ name: 'Foo' });
ExObj.set(my_obj, 'version', '1.0');
});The UMD build registers itself as an anonymous AMD module when define.amd is
available.
Deno
From an extracted source package:
import ExObj from './src/index.js';
const my_obj = ExObj.parse('{"name":"Foo"}');
ExObj.set(my_obj, 'version', '1.0');The generated ESM distribution works as well:
import ExObj from './dist/exobj.esm.js';Once published to npm, Deno can also consume the package through its npm:
specifier. No Node.js compatibility flag is required by the ExObj runtime.
Controlled mutation
const my_obj = ExObj.create({ name: 'Foo' });
// Throws TypeError:
my_obj.version = '1.0';
ExObj.set(my_obj, 'version', '1.0');
ExObj.set(my_obj, 'auth', {
name: 'quocta',
homepage: 'https://quocta.dev',
});
ExObj.unset(my_obj, 'name');
console.log(my_obj.version); // "1.0"
console.log(my_obj.auth.name); // "quocta"Every managed object and nested proxy also provides non-enumerable instance methods:
my_obj.__set('version', '2.0');
my_obj.auth.__set('name', 'new name');
my_obj.__unset('name');
const compact_json = my_obj.__json();
const pretty_json = my_obj.__json(null, 2);
const mutable_snapshot = my_obj.toJSON();__set() returns the same managed instance. __unset() returns whether an own
field was removed. toJSON() returns a detached mutable snapshot, while
__json() returns serialized JSON.
ExObj.create() and ExObj.set() clone incoming data. Retaining and mutating a
reference to the input object cannot mutate the protected ExObj graph.
The following operations are blocked at every depth:
my_obj.name = 'Bar';
delete my_obj.name;
Object.defineProperty(my_obj, 'name', { value: 'Bar' });
Object.setPrototypeOf(my_obj, null);
my_obj.roles.push('admin');
my_obj.auth.name = 'Bar';Object.freeze(my_obj) is also blocked because it attempts to change the
target's extensibility. The proxy already provides mutation protection.
Standard object operations
ExObj proxies work with standard reflection and enumeration APIs:
Object.keys(my_obj);
Object.values(my_obj);
Object.entries(my_obj);
Object.hasOwn(my_obj, 'version');
'version' in my_obj; // true
'__set' in my_obj; // trueInstance methods behave like prototype methods: they are available through the
in operator but are not own properties and never appear in keys, values,
entries, object spreads, or JSON output.
Safe JSON compatibility
ExObj.parse() and ExObj.stringify() mirror the native JSON.parse() and
JSON.stringify() call signatures:
const account = ExObj.parse(`{
"name": "Foo",
"auth": {
"name": "quocta"
}
}`);
account.auth.name = 'other'; // TypeError
const compact_json = ExObj.stringify(account);
const pretty_json = ExObj.stringify(account, null, 2);
const instance_json = account.__json();
const automatic_json = JSON.stringify(account); // Uses account.toJSON().Parsing always uses the native JSON.parse() implementation and never uses
eval. Parsed objects and arrays are cloned and wrapped in deep read-only
proxies. Top-level JSON primitives are returned unchanged. A __proto__ field
remains a normal own data property and cannot change the object's prototype.
Native JSON APIs also work directly:
const json = JSON.stringify(account); // No internal proxy metadata is exposed.
const plain_object = JSON.parse(json); // A normal mutable object.The reviver, replacer, and space parameters are supported:
const data = ExObj.parse('{"version":1}', (key, value) =>
key === 'version' ? `v${value}` : value,
);
const json = ExObj.stringify(data, ['version'], 2);Native rules are preserved: malformed JSON throws SyntaxError, undefined
object fields are omitted, undefined array entries become null, and BigInt
throws TypeError during serialization.
Strict TypeScript support
The package provides format-correct declarations for ESM (index.d.ts),
CommonJS (index.d.cts), and the browser UMD global (index.umd.d.cts). Deno
discovers the ESM declarations through @ts-self-types.
import ExObj, {
type ExObjInstance,
type DeepMutable,
} from 'exobj';
interface Account {
name: string;
auth: {
enabled: boolean;
};
roles: string[];
}
const my_obj: ExObjInstance<Account> = ExObj.create<Account>({
name: 'Foo',
auth: { enabled: true },
roles: ['admin'],
});
my_obj.name = 'Other'; // TypeScript error: deeply readonly.
my_obj.roles.push('editor'); // TypeScript error: readonly array.
const extended_obj = my_obj.__set('version', '1.0' as const);
extended_obj.version; // Typed as the literal "1.0".
const snapshot: DeepMutable<Account> = my_obj.toJSON();
snapshot.auth.enabled = false; // Valid: snapshots are mutable.Managed values carry a private type brand, so static mutation methods reject
ordinary objects at compile time. Nested objects are typed as nested ExObj
instances and expose their own instance methods. Subscriptions preserve the
root schema through generic ChangeEvent<T> and ExObjSubscription<T> types.
Change subscriptions
const subscriber = ExObj.create_subscription(my_obj);
subscriber.on('changed', ({ field, old_value, new_value }) => {
console.log(
`Value of field ${String(field)} changed ` +
`from ${String(old_value)} to ${String(new_value)}.`,
);
});
ExObj.set(my_obj, 'version', '2.0');
subscriber.unsubscribe();Events run synchronously in registration order. A changed event contains:
{
type: 'set' | 'unset';
target: object;
field: string | symbol;
path: readonly PropertyKey[];
had_field: boolean;
old_value: unknown;
new_value: unknown;
}A subscription created for the root receives mutations performed through nested proxies:
const account = ExObj.create({ auth: { name: 'old' } });
const subscriber = ExObj.create_subscription(account);
subscriber.on('changed', ({ path }) => console.log(path));
ExObj.set(account.auth, 'name', 'new'); // ["auth", "name"]Use off('changed', handler) to remove one handler. unsubscribe() removes all
handlers, detaches the subscription from the object, and is safe to call more
than once.
API reference
ExObj.create(initial_value?)
Creates a deep read-only proxy from a plain object or array.
ExObj.parse(text, reviver?)
Parses with native JSON.parse(). Objects and arrays become ExObj proxies;
top-level primitives remain primitives. Data is cloned after the reviver runs,
so a reference retained by the reviver cannot mutate the resulting ExObj.
ExObj.stringify(value, replacer?, space?)
Serializes according to native JSON.stringify() rules. Accepts ExObj values,
plain values, replacer functions, property lists, and pretty-print spacing.
ExObj.set(target, field, value)
Adds or replaces an own field and returns target. The field can be a string or
symbol. A nested ExObj proxy can be used as the target.
target.__set(field, value)
Instance equivalent of ExObj.set(). Returns the same proxy with a refined
TypeScript result type that includes the assigned field.
ExObj.unset(target, field)
Deletes an own field. Returns true when a field was deleted and false when
it did not exist.
target.__unset(field)
Instance equivalent of ExObj.unset().
target.__json(replacer?, space?)
Serializes the managed object with native JSON rules.
target.toJSON()
Returns a detached deep mutable snapshot. JSON.stringify() invokes this
method automatically.
ExObj.create_subscription(target)
Returns { on, off, unsubscribe, active }. The supported event is changed.
ExObj.is_ex_obj(value)
Returns whether a value is a proxy managed by ExObj.
ExObj.to_object(target)
Returns a deep mutable clone suitable for snapshots or integration with code that expects an ordinary object.
Supported data
ExObj accepts plain objects, arrays, and primitive values. Functions, class instances, and circular references are rejected explicitly so they cannot provide hidden mutation paths.
The field names __set, __unset, __json, and toJSON are reserved at every
object depth for instance methods. Creating, parsing, or assigning data with
one of these own field names throws TypeError.
Development
npm run build
npm run typecheck
npm test
npm run pack:checknpm run build produces all three distribution files from src/core.js. The
test suite checks the source API, Node.js CJS and ESM, browser global scripts,
AMD, UMD, JSON behavior, and runtime-neutral Deno compatibility.
Run the native Deno test when Deno is installed:
deno test ./test/deno.test.jsLicense
MIT
