Skip to content

@studnicky/json ​

JSON/object value-tools: deep merge, clone, immutable snapshots, freeze, path access, sort, patch.

What it is ​

A composable JSON and object-value primitive for copying, merging, freezing, patching, accessing, and sorting caller-owned data. It supplies value mechanics and validation contracts without defining a stored document or application model.

What it is for ​

Northstar Books uses it to safely compose catalogue configuration, keep immutable inventory snapshots, and represent a change as a portable JSON Patch. Consumers retain ownership of data shapes, persistence, and the policies that authorize a change.

Northstar Books examples ​

The runnable merge-and-clone example combines base catalogue settings with a storefront overlay and proves that nested values are detached and arrays follow the selected merge rule. The runnable immutable-snapshot example freezes a stock view, proving that a pricing or availability calculation can retain a stable input while other code continues updating its source record.

Public entrypoints ​

Import pathUse it when
@studnicky/json/nodeA Northstar Books server manipulates JSON or object values for catalogue and inventory work.
@studnicky/json/browserA storefront browser needs the same portable value operations.
@studnicky/json/entitiesAn adapter validates schema-expressible patch and path data contracts.
@studnicky/json/interfacesTypeScript code shares patch, path, configuration, and state contracts.

Install ​

bash
pnpm add @studnicky/json

Runtime imports ​

Use @studnicky/json/node in Node.js and @studnicky/json/browser in browser bundles. Both runtime entry points expose the same API. Types, interfaces, and entities use their shared subpaths.

Merge and Clone ​

Northstar's storefront team wants to layer a seasonal overlay — holiday pricing, a new tag list — on top of the base catalogue config without hand-writing a merge function or accidentally mutating the original settings object. Merge.deep does exactly that: nested objects merge key by key with the overlay winning on conflict, while arrays are swapped out wholesale rather than combined, unless a subclass like ConcatMerge below overrides that rule to concatenate instead. Clone.deep handles the companion problem — producing an independent copy of a record, dates, maps, and sets included, that shares no references with the original, so editing the clone can never leak back into the source:

ts
import { Clone, Merge } from '../src/index.js';
import { MergeCloneFixture } from './fixtures/MergeCloneFixture.js';

// ---------------------------------------------------------------------------
// Merge.deep — nested objects
// ---------------------------------------------------------------------------

const base = MergeCloneFixture.Base;
const overlay = MergeCloneFixture.Overlay;

const merged = Merge.deep(base, overlay);

console.log('merged.b:', merged.b);
console.log('merged.tags:', merged.tags);

// ---------------------------------------------------------------------------
// Clone.deep — structural equality, no shared references
// ---------------------------------------------------------------------------

const original = MergeCloneFixture.Original;
const copy = Clone.deep(original);

console.log('copy.createdAt:', copy.createdAt);
console.log('same reference?', copy === original);

// ---------------------------------------------------------------------------
// ConcatMerge — static-override subclass: arrays concatenate instead of replace
// ---------------------------------------------------------------------------

class ConcatMerge extends Merge {
  protected static override mergeArrays<TOverlay>(leftValue: unknown, rightValue: TOverlay): TOverlay {
    if (Array.isArray(leftValue) && Array.isArray(rightValue)) {
      rightValue.unshift(...leftValue);
    }
    return rightValue;
  }
}

const concatResult = ConcatMerge.deep({ 'tags': ['a', 'b'] }, { 'tags': ['c'] });
const plainResult = Merge.deep({ 'tags': ['a', 'b'] }, { 'tags': ['c'] });

console.log('concatResult.tags:', concatResult.tags);
console.log('plainResult.tags:', plainResult.tags);

Try it ​

Loading example…

The output shows overlay keys winning on conflict, base keys preserved, arrays replaced atomically by default, and ConcatMerge demonstrating the static-override subclass pattern.

Merge.deep uses generic overloads that preserve the caller's value domain: two object inputs return their intersection, same-type inputs retain that type, and mixed inputs return the input union. Runtime merging remains limited to arrays and plain objects; Date, Map, Set, regular expressions, class instances, and other non-plain objects remain atomic values.

Immutable snapshots ​

A pricing calculation is reading a stock record while, somewhere else in the request, another piece of code is about to update that exact same object — Northstar needs the calculation to see a stable, frozen-in-time view no matter what happens next. ImmutableSnapshot.from(value) takes that stock record and hands back a fully detached, deeply frozen copy, built with structuredClone and Frozen.deepFreeze. Below, the source object's name and role set keep changing after the snapshot is taken, but the snapshot never notices — and trying to mutate its frozen Map throws FrozenMutationError instead of silently succeeding.

ts
import { FrozenMutationError, ImmutableSnapshot } from '@studnicky/json/browser';

const source = {
  'profile': { 'name': 'Ada' },
  'roles': new Set([ 'reader' ]),
  'sessions': new Map([[ 'active', { 'count': 1 } ]])
};

const snapshot = ImmutableSnapshot.from(source);
source.profile.name = 'Grace';
source.roles.add('writer');

let mapMutationBlocked = false;
try {
  snapshot.sessions.set('pending', { 'count': 0 });
} catch (error) {
  mapMutationBlocked = error instanceof FrozenMutationError;
}

if (snapshot.profile.name !== 'Ada' || snapshot.roles.has('writer') || !mapMutationBlocked) {
  throw new Error('Immutable snapshot contract failed.');
}

console.log('snapshot profile:', snapshot.profile);
console.log('snapshot roles:', [...snapshot.roles]);
console.log('snapshot map mutation blocked:', mapMutationBlocked);
Loading example…

Patch, predicates, and Frozen ​

Northstar wants to describe "publish this draft" as data — a portable list of edits — instead of writing bespoke mutation code for every workflow that changes a document's status. The example below builds exactly that: a Patch that replaces status, adds a publishedAt date, and removes the now-irrelevant count field, then applies it to a working document in one call; a test operation shows how a patch can also assert a value and throw PatchError when reality doesn't match. Alongside the patch, Predicates proves out the structural checks — areDeeplyEqual, isPlainObject, isRecord — a consumer needs before it trusts what it's about to patch, and Frozen.deepFreeze locks a nested document tree at every level, including anything circular, so it can't be edited after the fact:

ts
import { JsonObjectEntity } from '../src/entities/index.js';
import { Frozen, Patch, PatchError } from '../src/index.js';
import { PatchPredicatesFixture } from './fixtures/PatchPredicatesFixture.js';

// ---------------------------------------------------------------------------
// Working documents are trusted JSON values produced by this example.
// ---------------------------------------------------------------------------

const workingDocs = {
  'document': JsonObjectEntity.create({ 'count': 0, 'meta': { 'version': 1 }, 'status': 'draft' }),
  'documentThree': JsonObjectEntity.create(),
  'documentTwo': JsonObjectEntity.create({ 'name': 'alpha', 'tags': ['a', 'b'] })
};

// ---------------------------------------------------------------------------
// Patch — instance-based RFC-6902 JSON Patch
// ---------------------------------------------------------------------------

// Create a patch with an array of operations
const patch = Patch.create([
  { 'op': 'replace', 'path': '/status', 'value': 'published' },
  { 'op': 'add', 'path': '/publishedAt', 'value': '2026-06-22' },
  { 'op': 'remove', 'path': '/count' }
]);

patch.apply(workingDocs.document);

console.log('document after patch:', workingDocs.document);

Patch.create({ 'op': 'add', 'path': '/score', 'value': 100 }).apply(workingDocs.documentTwo);
Patch.create({ 'op': 'replace', 'path': '/name', 'value': 'beta' }).apply(workingDocs.documentTwo);

console.log('documentTwo after patches:', workingDocs.documentTwo);

const combined = Patch.create([
  { 'op': 'add', 'path': '/x', 'value': 1 },
  { 'op': 'add', 'path': '/y', 'value': 2 }
]);

combined.apply(workingDocs.documentThree);

console.log('documentThree after combined patch:', workingDocs.documentThree);

// test operation throws PatchError on mismatch
const strictPatch = Patch.create({ 'op': 'test', 'path': '/name', 'value': 'WRONG' });

assert.throws(
  () => { strictPatch.apply({ 'name': 'actual' }); },
  PatchError,
  'test operation throws PatchError on mismatch'
);

console.log('isEmpty:', Patch.create([]).isEmpty(), Patch.create({ 'op': 'add', 'path': '/a', 'value': 1 }).isEmpty());

// ---------------------------------------------------------------------------
// Predicates — deep equality and type guards
// ---------------------------------------------------------------------------

const nestedEqual = Predicates.areDeeplyEqual({ 'a': [1, 2] }, { 'a': [1, 2] });
console.log('deepEqual nested arrays:', nestedEqual);
console.log('isPlainObject({}):', Predicates.isPlainObject({}));
console.log('isRecord({a:1}):', Predicates.isRecord({ 'a': 1 }));

// ---------------------------------------------------------------------------
// Frozen — deep freeze JSON data
// ---------------------------------------------------------------------------

const tree = PatchPredicatesFixture.Tree;
const frozen = Frozen.deepFreeze(tree);

console.log('frozen === tree:', frozen === tree);
console.log('Object.isFrozen(frozen):', Object.isFrozen(frozen));

Patch contracts and validation ​

PatchOperationCoreEntity is the schema-derived contract for the shared RFC-6902 fields. Its Schema, Type, and validate members define and validate required string path, the supported op values, and optional string from.

Patch.diff(before, after) creates a validated RFC-6902 Patch between two independently obtained JSON values. It validates both JSON boundaries and uses the same recursive operation emitter as Draft.producePatch, without requiring a draft mutation recipe. Read the generated operations through patch.operations or apply the patch directly.

PatchOperationInterface extends PatchOperationCoreEntity.Type with an optional value: JSONSchema7Type. Patch.create accepts unknown input, rejects fields outside from, op, path, and value, and validates each operation through PatchOperationEntity.intake, the opcode-aware schema that enforces which of from/value each op permits. When value is present, validation traverses the complete value and rejects nested functions, symbols, bigints, undefined, cycles, and other non-JSON values.

JSONSchema7Type belongs to json-schema. Import it directly from json-schema when annotating operation values passed to Patch.create(operations); its declarations come from the package's direct @types/json-schema dependency. @studnicky/json does not export a proxy alias for the dependency-owned type. The patch instance's readonly operations property is the public projection of its validated operations and returns deeply isolated values.

The remaining public interfaces describe operation results and path wildcards:

InterfaceContract
PatchApplyResultInterfaceA success: boolean, returned value: unknown, and optional error: string.
PathWildcardResultInterfaceThe Path.get wildcard sentinel with array: unknown[], isWildcard: true, and remainingPath: string[].

DraftNodeStateEntity, PatchApplyResultStatusEntity, and PathWildcardResultEntity own the schema-expressible fields composed by these runtime interfaces. Object graphs, maps, and unknown values remain interface members because they are not pure-data schema contracts.

Path and Sort ​

A JSON Patch operation hands Northstar a pointer like /items/0/name, but application code wants to read that value the way JavaScript actually does — items[0].name. Path.toAccess makes that conversion, and Path.get reads a value straight off that dotted path while refusing to touch __proto__ or constructor, so a malicious path string can't climb the prototype chain. Sort.natural rounds out the toolkit by sorting strings the way a person would — file2 before file10 — rather than lexicographically, with longestFirst/shortestFirst variants for ordering by length instead. Hash and StructuralHash publish from @studnicky/types.

ts
import { Path, Sort } from '../src/index.js';
import { PathSortFixture } from './fixtures/PathSortFixture.js';

// ---------------------------------------------------------------------------
// Path.toAccess — JSON Pointer → JS access notation
// ---------------------------------------------------------------------------

console.log('Path.toAccess(/items/0/name):', Path.toAccess('/items/0/name'));
console.log('Path.toAccess(/user/address/city):', Path.toAccess('/user/address/city'));
console.log('Path.toAccess():', JSON.stringify(Path.toAccess('')));
console.log('Path.toAccess(/):', JSON.stringify(Path.toAccess('/')));

// ---------------------------------------------------------------------------
// Path.get — proto-safe dot-path read
// ---------------------------------------------------------------------------

const document = PathSortFixture.Document;

console.log('Path.get user.address.city:', Path.get(document, 'user.address.city'));
console.log('Path.get items[0].name:', Path.get(document, 'items[0].name'));
console.log('Path.get missing.key:', Path.get(document, 'missing.key'));
console.log('Path.get __proto__:', Path.get(document, '__proto__'));

// ---------------------------------------------------------------------------
// Sort.natural — numeric substrings sorted as numbers
// ---------------------------------------------------------------------------

const files = ['file10', 'file2', 'file1'].toSorted(Sort.natural);
const byLength = ['id', 'type', 'description'].toSorted(Sort.longestFirst);
const byLengthAsc = ['description', 'id', 'type'].toSorted(Sort.shortestFirst);

console.log('natural sort:', files);
console.log('longestFirst:', byLength);
console.log('shortestFirst:', byLengthAsc);

Schema validation ​

Schema-backed entities use EntityCompiler from @studnicky/entity. Import EntityCompiler and SchemaIntakeError from its /node or /browser runtime entry point, and import EntityCreateFunctionInterface, EntityIntakeFunctionInterface, and EntityValidateFunctionInterface from @studnicky/entity/interfaces. @studnicky/json provides JSON value operations; it does not provide schema validation APIs.

Extending ​

Most utilities are pure-static; Patch is instance-based. Compose the static utilities in a domain-specific class or subclass their protected customization seams. The merge-clone example above shows subclassing Merge to change array-merge behaviour.

Source on GitHub

Entities ​

@studnicky/json/entities exports every schema namespace in src/entities.

typescript
import { PatchOperationCoreEntity } from "@studnicky/json/entities";

Interfaces ​

@studnicky/json/interfaces exports every TypeScript interface in src/interfaces, including configuration and state contracts.

typescript
import type { PatchOperationInterface } from "@studnicky/json/interfaces";

Exports ​

SymbolPurposeImport path
CloneProvides clone functionality.@studnicky/json/node
DraftProvides immutable drafting and direct RFC-6902 comparison.@studnicky/json/node
FrozenProvides frozen functionality.@studnicky/json/node
ImmutableSnapshotCreates detached deeply frozen snapshots.@studnicky/json/node
MergeProvides merge functionality.@studnicky/json/node
PatchProvides patch functionality.@studnicky/json/node
PathProvides path functionality.@studnicky/json/node
SortProvides sort functionality.@studnicky/json/node
CloneErrorRepresents a value that cannot be deep-cloned.@studnicky/json/node
FrozenMutationErrorRepresents frozen mutation failures.@studnicky/json/node
ImmutableSnapshotErrorRepresents snapshot isolation failures.@studnicky/json/node
JsonErrorRepresents json failures.@studnicky/json/node
PatchErrorRepresents patch failures.@studnicky/json/node
SameKindErrorRepresents a derived value whose structural kind differs from its source.@studnicky/json/node