@studnicky/json
JSON/object value-tools: deep merge, clone, equal, freeze, path access, sort, patch, hash.
Install
pnpm add @studnicky/jsonMerge and Clone
Deep merge nested objects: overlay wins on conflict, base keys are preserved, and arrays are replaced atomically by default. Clone produces a new object with no shared references, with full Date/Map/Set awareness:
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 as { 'tags': string[] }).tags);
// ---------------------------------------------------------------------------
// Clone.deep — structural equality, no shared references
// ---------------------------------------------------------------------------
const original = MergeCloneFixture.Original;
const copy = Clone.deep(original);
console.log('copy.created:', copy.created);
console.log('same reference?', copy === original);
// ---------------------------------------------------------------------------
// ConcatMerge — static-override subclass: arrays concatenate instead of replace
// ---------------------------------------------------------------------------
class ConcatMerge extends Merge {
protected static override mergeArrays(base: unknown[], overlay: unknown[]): unknown[] {
return [...base, ...overlay];
}
}
const concatResult = ConcatMerge.deep({ 'tags': ['a', 'b'] }, { 'tags': ['c'] });
const plainResult = Merge.deep({ 'tags': ['a', 'b'] }, { 'tags': ['c'] });
console.log('concatResult.tags:', (concatResult as { 'tags': string[] }).tags);
console.log('plainResult.tags:', (plainResult as { 'tags': string[] }).tags);Try it
/** merge-clone — deep merge with nested objects and Clone with Date/array awareness. Run: npx tsx packages/json/examples/merge-clone.ts */
import assert from 'node:assert/strict';
// #region usage
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 as { 'tags': string[] }).tags);
// ---------------------------------------------------------------------------
// Clone.deep — structural equality, no shared references
// ---------------------------------------------------------------------------
const original = MergeCloneFixture.Original;
const copy = Clone.deep(original);
console.log('copy.created:', copy.created);
console.log('same reference?', copy === original);
// ---------------------------------------------------------------------------
// ConcatMerge — static-override subclass: arrays concatenate instead of replace
// ---------------------------------------------------------------------------
class ConcatMerge extends Merge {
protected static override mergeArrays(base: unknown[], overlay: unknown[]): unknown[] {
return [...base, ...overlay];
}
}
const concatResult = ConcatMerge.deep({ 'tags': ['a', 'b'] }, { 'tags': ['c'] });
const plainResult = Merge.deep({ 'tags': ['a', 'b'] }, { 'tags': ['c'] });
console.log('concatResult.tags:', (concatResult as { 'tags': string[] }).tags);
console.log('plainResult.tags:', (plainResult as { 'tags': string[] }).tags);
// #endregion usage
assert.equal(merged.b.y, 99, 'overlay primitive wins');
assert.equal(merged.b.x, 10, 'base key preserved');
assert.equal(merged.b.z, 3, 'overlay key added');
assert.equal((merged as { 'c': string }).c, 'new', 'overlay top-level key added');
assert.deepEqual((merged as { 'tags': string[] }).tags, ['beta'], 'arrays replaced atomically');
assert.deepEqual(copy, original, 'deep clone is structurally equal');
assert.notEqual(copy, original, 'clone is a different reference');
assert.notEqual(copy.items, original.items, 'nested array is a different reference');
assert.notEqual(copy.created, original.created, 'Date is cloned to a new instance');
assert.equal(copy.created.getTime(), original.created.getTime(), 'Date value is preserved');
assert.deepEqual(
(concatResult as { 'tags': string[] }).tags,
['a', 'b', 'c'],
'ConcatMerge concatenates arrays'
);
assert.deepEqual(
(plainResult as { 'tags': string[] }).tags,
['c'],
'Merge.deep still replaces arrays atomically'
);
console.log('merge-clone: all assertions passed');
The output shows overlay keys winning on conflict, base keys preserved, arrays replaced atomically by default, and ConcatMerge demonstrating the static-override subclass pattern.
Patch, DataType, and Frozen
Apply RFC-6902 JSON Patch operations using either the constructor or static factory methods. DataType provides deep structural equality and type guards. Frozen.deepFreeze freezes all levels safely, including circular structures:
import { DataType, Frozen, Patch, PatchError } from '../src/index.js';
import { PatchDatatypeFixture } from './fixtures/PatchDatatypeFixture.js';
// ---------------------------------------------------------------------------
// Working documents — mutated in-place by Patch.apply/DataType.hasCycle below,
// so they stay local rather than living as shared, read-only fixture data.
// ---------------------------------------------------------------------------
const workingDocs: {
'circular': Record<string, unknown>;
'cyclic': Record<string, unknown>;
'doc': Record<string, unknown>;
'doc2': Record<string, unknown>;
'doc3': Record<string, unknown>;
} = {
'circular': { 'name': 'cycle' },
'cyclic': { 'a': 1 },
'doc': { 'count': 0, 'meta': { 'version': 1 }, 'status': 'draft' },
'doc2': { 'name': 'alpha', 'tags': ['a', 'b'] },
'doc3': {}
};
// ---------------------------------------------------------------------------
// Patch — instance-based RFC-6902 JSON Patch
// ---------------------------------------------------------------------------
// Using the constructor 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.doc);
console.log('doc after patch:', workingDocs.doc);
// Static factory methods
Patch.add('/score', 100).apply(workingDocs.doc2);
Patch.replace('/name', 'beta').apply(workingDocs.doc2);
console.log('doc2 after static patches:', workingDocs.doc2);
// Patch.combine merges multiple patches
const combined = Patch.combine(
Patch.add('/x', 1),
Patch.add('/y', 2)
);
combined.apply(workingDocs.doc3);
console.log('doc3 after combined patch:', workingDocs.doc3);
// test operation throws PatchError on mismatch
const strictPatch = Patch.test('/name', 'WRONG');
assert.throws(
() => { strictPatch.apply({ 'name': 'actual' }); },
PatchError,
'test operation throws PatchError on mismatch'
);
console.log('isEmpty:', Patch.create([]).isEmpty(), Patch.add('/a', 1).isEmpty());
// ---------------------------------------------------------------------------
// DataType — deep equality and type guards
// ---------------------------------------------------------------------------
const nestedEqual = DataType.deepEqual({ 'a': [1, 2] }, { 'a': [1, 2] });
const nanEqual = DataType.deepEqual(Number.NaN, Number.NaN);
const d1 = new Date(1_000_000);
const d2 = new Date(1_000_000);
const m1 = PatchDatatypeFixture.M1;
const m2 = PatchDatatypeFixture.M2;
console.log('deepEqual nested arrays:', nestedEqual);
console.log('NaN equals NaN:', nanEqual);
console.log('Date equality:', DataType.deepEqual(d1, d2));
console.log('Map equality:', DataType.deepEqual(m1, m2));
console.log('isPlainObject({}):', DataType.isPlainObject({}));
console.log('isRecord({a:1}):', DataType.isRecord({ 'a': 1 }));
workingDocs.cyclic.self = workingDocs.cyclic;
console.log('hasCycle (cyclic):', DataType.hasCycle(workingDocs.cyclic));
console.log('hasCycle (plain):', DataType.hasCycle({ 'a': { 'b': 1 } }));
// ---------------------------------------------------------------------------
// Frozen — cycle-safe deep freeze
// ---------------------------------------------------------------------------
const tree = PatchDatatypeFixture.Tree;
const frozen = Frozen.deepFreeze(tree);
console.log('frozen === tree:', frozen === tree);
console.log('Object.isFrozen(frozen):', Object.isFrozen(frozen));
workingDocs.circular.back = workingDocs.circular;
Frozen.deepFreeze(workingDocs.circular);
console.log('circular frozen safely:', Object.isFrozen(workingDocs.circular));Path, Sort, Hash, and StructuralHash
Convert JSON Pointers to JS access notation, read values via proto-safe dot-paths, sort arrays naturally, and produce deterministic FNV-1a hashes. StructuralHash strips annotation-only keys ($id, title, description) before hashing:
import { Hash, Path, Sort, StructuralHash } from '../src/index.js';
import { PathSortHashFixture } from './fixtures/PathSortHashFixture.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 doc = PathSortHashFixture.Doc;
console.log('Path.get user.address.city:', Path.get(doc, 'user.address.city'));
console.log('Path.get items[0].name:', Path.get(doc, 'items[0].name'));
console.log('Path.get missing.key:', Path.get(doc, 'missing.key'));
console.log('Path.get __proto__:', Path.get(doc, '__proto__'));
// ---------------------------------------------------------------------------
// Sort.natural — numeric substrings sorted as numbers
// ---------------------------------------------------------------------------
const files = ['file10', 'file2', 'file1'].sort(Sort.natural);
const byLength = ['id', 'type', 'description'].sort(Sort.longestFirst);
const byLengthAsc = ['description', 'id', 'type'].sort(Sort.shortestFirst);
console.log('natural sort:', files);
console.log('longestFirst:', byLength);
console.log('shortestFirst:', byLengthAsc);
// ---------------------------------------------------------------------------
// Hash.value — deterministic FNV-1a 32-bit hex
// ---------------------------------------------------------------------------
const h1 = Hash.value({ 'a': 1, 'b': 2 });
const h2 = Hash.value({ 'a': 1, 'b': 2 });
console.log('h1:', h1, 'h2:', h2, 'equal:', h1 === h2);
// ---------------------------------------------------------------------------
// StructuralHash.of — strips annotation-only keys before hashing
// ---------------------------------------------------------------------------
const schemaWithMeta = PathSortHashFixture.SchemaWithMeta;
const schemaBare = PathSortHashFixture.SchemaBare;
console.log('StructuralHash with meta:', StructuralHash.of(schemaWithMeta));
console.log('StructuralHash bare:', StructuralHash.of(schemaBare));
console.log('equal (annotations stripped):', StructuralHash.of(schemaWithMeta) === StructuralHash.of(schemaBare));SchemaValidator
Compile a JSON Schema 2020-12 document into a reusable type-guard predicate, backed by Ajv (strict: true, allErrors: true, ajv-formats registered). Declare a single schema as the source of truth and derive both the compile-time type and the runtime guard from it, so there is no second, hand-written validator to drift out of sync:
import { SchemaValidator } from '@studnicky/json';
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
count: { type: 'number' },
},
required: ['id', 'count'],
additionalProperties: false,
} as const;
interface RecordType {
id: string;
count: number;
}
// Compile once at module load and reuse — compilation is the expensive step.
const isRecord = SchemaValidator.compile<RecordType>(schema);
if (isRecord(payload)) {
payload.count; // narrowed to RecordType
} else {
// isRecord.errors carries Ajv's ErrorObject[] after every call
SchemaValidator.formatErrors(isRecord.errors);
// "(root): must have required property 'count'"
}SchemaValidator.compile returns Ajv's ValidateFunction<TValidated> directly — it already narrows unknown to TValidated and exposes .errors. SchemaValidator.formatErrors renders that array into one human-readable line, falling back to 'invalid payload' when there are no errors. Override the protected static formatError step in a subclass to customise per-error wording.
Subpath exports
| Subpath | Contents |
|---|---|
@studnicky/json | Clone, DataType, Frozen, Hash, Merge, Patch, Path, Sort, StructuralHash, SchemaValidator, PatchError |
@studnicky/json/json | All utility classes |
@studnicky/json/types | DeepMergeType, PatchApplyResultType, PatchOperationType, PatchOpVariantType |
@studnicky/json/errors | PatchError |
@studnicky/json/interfaces | PathWildcardResultType |
@studnicky/json/schema | SchemaValidator |
Extending
Since the utilities are pure-static, compose them by wrapping in a domain-specific static class. The merge-clone example above shows subclassing Merge to change array-merge behaviour.