Skip to content

@studnicky/json

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

Install

bash
pnpm add @studnicky/json

Merge 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:

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

Deep merge and clone
/** 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');
Output
Press Execute to run this example against the real library.

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:

ts
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:

ts
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:

ts
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

SubpathContents
@studnicky/jsonClone, DataType, Frozen, Hash, Merge, Patch, Path, Sort, StructuralHash, SchemaValidator, PatchError
@studnicky/json/jsonAll utility classes
@studnicky/json/typesDeepMergeType, PatchApplyResultType, PatchOperationType, PatchOpVariantType
@studnicky/json/errorsPatchError
@studnicky/json/interfacesPathWildcardResultType
@studnicky/json/schemaSchemaValidator

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.

Source on GitHub