UNPKG

merge-change

Version:

Advanced library for deep merging, patching, and immutable updates of data structures. Features declarative operations for specific merging behaviors, property management, custom type merging rules, and difference tracking. Supports complex data transform

942 lines (768 loc) 20.5 kB
# merge-change merge-change is a flexible JavaScript and TypeScript library for deep merge operations, state updates, and structural data transformations. It supports custom merge rules, declarative operations, and immutable or in-place updates. The library provides a simple but flexible API for complex data transformations with support for declarative operations, custom type merging rules, and difference tracking. You can define your own merging algorithm between specific data types. The main difference from other deep merging libraries is the ability to not only merge object properties but also delete and redefine values within a single merge operation. ## Table of Contents - [Installation](#installation) - [Core Functions](#core-functions) - [merge](#merge) - [update](#update) - [patch](#patch) - [Declarative Operations](#declarative-operations) - [$set](#set) - [$unset](#unset) - [$leave](#leave) - [$push](#push) - [$concat](#concat) - [$pull](#pull) - [Custom Merge Logic](#custom-merge-logic) - [Utility Functions](#utility-functions) - [TypeScript Support](#typescript-support) - [Path Format Options](#path-format-options) - [License](#license) ## Installation Install with npm: ```sh npm install --save merge-change ``` ## Functions ### `merge(...values: T): MergedAll<T>` Deep merge all values without changing them. Creates a new instance. This is ideal for creating a new object by extending the original object. ```typescript import {merge} from 'merge-change'; const first = { property: { one: true, two: 2, }, other: { value: 'string' } }; const second = { property: { three: 3, two: 222, inner: [1, 2, 3] } }; // Merge first with second and create new object const result = merge(first, second) expect(result).toEqual({ property: { one: true, two: 222, // replaced number three: 3, // new inner: [1, 2, 3] // by default array replaced }, other: { value: 'string' } }); // First object not changed expect(first).toEqual({ property: { one: true, two: 2, inner: [0, 1] }, other: { value: 'string' } }); // Second object not changed expect(second).toEqual({ property: { three: 3, two: 222, inner: [1, 2, 3] } }); ``` ### `update(...values: T): MergedAll<T>` Performs an immutable merge, creating new instances only for properties that have changed. This is perfect for state management in frameworks like React or Redux, as it preserves object references for unchanged parts of the data structure. ```typescript import {update} from 'merge-change'; const source = { property: { one: true, two: 2, deepProp: { field: 1 } }, other: { value: 'string' } }; const updates = { property: { three: 3, two: 222, inner: [1, 2, 3] } }; // Immutable update of first value with second. Return new instance if changed. const newSource = update(source, updates) expect(newSource).toEqual({ property: { one: true, two: 222, // replaced number deepProp: { // reference to first.property.deepProp field: 1 }, three: 3, // new inner: [1, 2, 3] // by default array replaced }, other: { value: 'string' } }); // First object not changed! expect(first).toEqual({ property: { one: true, two: 2, inner: [0, 1] }, other: { value: 'string' } }); // Second object not changed! expect(updates).toEqual({ property: { three: 3, two: 222, inner: [1, 2, 3] } }); // But the property "newSource.other" is a reference to "first.other" because it is not changed expect(first.other).toBe(newSource.other) // Also the property "newSource.property.deepProp" is a reference to "first.property.deepProp" because it is not changed expect(first.property.deepProp).toBe(newSource.property.deepProp) ``` ### `patch(...values: T): MergedAll<T>` Merges objects by mutating the original object. This is useful for patching existing objects without creating new instances. ```typescript import {patch} from 'merge-change'; const original = { a: { one: true, two: 2, deepProp: { // reference to first.property.deepProp field: 1 }, } }; const patchData = { a: { three: 3, } }; const newOriginal = patch(original, patchData) expect(newOriginal).toEqual({ a: { one: true, two: 2, deepProp: { field: 1 }, three: 3, } }); // First original object is a newOriginal expect(newOriginal).toBe(original); // Second object not changed! expect(patchData).toEqual({ a: { three: 3, } }); ``` ## Declarative Operations Declarative operations in the second or subsequent arguments allow you to perform deletion, reassignment, and other actions within a single merge operation. Declarative operations are supported in all methods: `merge`, `update`, `patch`. ### `$set` Sets or replaces properties without deep merging. The keys is a deep path to the property. Declarative operations can be combined with regular values. ```typescript const result = merge( { a: { one: 1, two: 2 }, b: { x: 200, y: 100 }, deepProp: { field: 1 }, }, { $set: { // declarative operation a: { // for replace value for "a" three: 3 }, 'deepProp.field': 20 // Property keys can be paths }, b: { // values for normal merge y: 300 } } ); expect(result).toEqual({ a: { // replaced three: 3 }, b: { x: 200, y: 300 // changed }, deepProp: { field: 1 }, }); ``` ### `$unset` Removes properties by name or path. ```typescript const result = merge( { a: { one: 1, two: 2 } }, { $unset: ['a.two'] } ); expect(result).toEqual({ a: { one: 1 } }); ``` You can use the asterisk (`*`) to remove all properties: ```typescript const result = merge( { a: { one: 1, two: 2 } }, { $unset: ['a.*'] } ); expect(result).toEqual({ a: {} }) ``` ### `$leave` Keeps only specified properties, removing all others. ```typescript import {merge} from 'merge-change'; const result = merge( { a: { one: 1, two: 2, three: 3 } }, { a: { $leave: ['two'] } } ); expect(result).toEqual({ a: { two: 2 } }) ``` ### `$push` Adds values to array properties. ```typescript const result = merge( { prop1: ['a', 'b'], prop2: ['a', 'b'] }, { $push: { prop1: ['c', 'd'], prop2: {x: 'c'} } } ); expect(result).toEqual({ prop1: ['a', 'b', ['c', 'd']], prop2: ['a', 'b', {x: 'c'}] }) ``` ### `$concat` Concatenates arrays. ```typescript const result = merge( { prop1: ['a', 'b'], prop2: ['a', 'b'] }, { $concat: { prop1: ['c', 'd'], prop2: {x: 'c'} } } ); expect(result).toEqual({ prop1: ['a', 'b', 'c', 'd'], prop2: ['a', 'b', {x: 'c'}] }) ``` ### `$pull` Removes elements from arrays by value equality. ```typescript const result = merge( { items: [1, 2, 3, 2, 4] }, { $pull: { items: 2 } } ); expect(result).toEqual({ items: [1, 3, 4] }) ``` ## Custom Merge Logic You can customize how specific types are merged by creating custom merge functions with the factory functions `createMerge`, `createUpdate`, and `createPatch`. Custom merge methods are named using the pattern `TypeName1_TypeName2`, where: - `TypeName1` is the native type or constructor name of the first value - `TypeName2` is the native type or constructor name of the second value The type names are determined by the `type()` function, which returns: - Native TypeScript types: `'string'`, `'number'`, `'boolean'`, `'object'`, `'Array'`, etc. - Class names: `'Date'`, `'Map'`, `'Set'`, `'MyCustomClass'`, etc. - Special types: `'null'`, `'undefined'` - `unknown` type for merging with any types ```typescript import {createMerge, createUpdate, createPatch} from 'merge-change'; // Create custom merge methods const customMethods = { // Method name is formed from the types: Array_Array - that always concatenate two arrays Array_Array(first, second, kind, mc) { // merge - create new array with deep clone if (kind === 'merge') { return first.concat(second).map(item => mc(undefined, item)); } // patch - mutate first array if (kind === 'patch') { first.splice(first.length, 0, ...second); return first; } // update - return first array if second is empty, or create new without clone if (second.length === 0) { return first; } else { return first.concat(second); } }, // Example with custom class MyClass_object(first, second, kind, mc) { // Custom logic for merging MyClass with a plain object // ... }, // Example with custom class and others types MyClass_unknown(first, second, kind, mc) { // Custom logic for merging MyClass with any other types // ... }, // Example with native types number_string(first, second, kind, mc) { // Custom logic for merging a number with a string // ... } }; // Create custom merge functions const customMerge = createMerge(customMethods); const customUpdate = createUpdate(customMethods); const customPatch = createPatch(customMethods); // Test the custom merge function const result = customMerge( {items: [1, 2]}, {items: [3, 4]} ); expect(result).toBeDefined(); // { items: [1, 2, 3, 4] } ``` ## Utility Functions ### `get(data, path, defaultValue = undefined, separator = '.')` Retrieves a value from a nested object using a string path. ```typescript import {get} from 'merge-change'; const obj = { a: { b: { c: 'value' }, items: [1, 2, 3] } }; // Get a nested property const value1 = get(obj, 'a.b.c'); expect(value1).toBeDefined(); // 'value' // Get an array element const value2 = get(obj, 'a.items.1'); expect(value2).toBeDefined(); // 2 // Get with a default value for non-existent paths const value3 = get(obj, 'a.x.y', 'default'); expect(value3).toBeDefined(); // 'default' // Get with a custom separator const value4 = get(obj, 'a/b/c', undefined, '/'); expect(value4).toBeDefined(); // 'value' ``` ### `set(data, path, value, skipExisting = false, separator = '.')` Sets a value in a nested object using a path, creating intermediate objects if needed. ```typescript import {set} from 'merge-change'; const obj = { a: { b: {} } }; // Set a nested property set(obj, 'a.b.c', 'value'); expect(obj).toBeDefined(); // { a: { b: { c: 'value' } } } // Set with a custom separator set(obj, 'a/b/d', 'another value', false, '/'); expect(obj).toBeDefined(); // { a: { b: { c: 'value', d: 'another value' } } } // Set only if the property doesn't exist set(obj, 'a.b.c', 'new value', true); expect(obj).toBeDefined(); // { a: { b: { c: 'value', d: 'another value' } } } // Create arrays when using numeric indices set(obj, 'a.items.0', 'first'); set(obj, 'a.items.1', 'second'); expect(obj).toBeDefined(); // { a: { b: { c: 'value', d: 'another value' }, items: ['first', 'second'] } } ``` ### `unset(data, path, separator = '.')` Removes a property from a nested object using a path. ```typescript import {unset} from 'merge-change'; const obj = { a: { b: { c: 'value', d: 'another value' }, items: [1, 2, 3] } }; // Remove a nested property unset(obj, 'a.b.c'); expect(obj).toBeDefined(); // { a: { b: { d: 'another value' }, items: [1, 2, 3] } } // Remove an array element unset(obj, 'a.items.1'); expect(obj).toBeDefined(); // { a: { b: { d: 'another value' }, items: [1, 3] } } // Remove all properties using asterisk unset(obj, 'a.b.*'); expect(obj).toBeDefined(); // { a: { b: {}, items: [1, 3] } } // Remove with a custom separator unset(obj, 'a/items', '/'); expect(obj).toBeDefined(); // { a: { b: {} } } ``` ### `diff(source, compare, options)` Calculates the difference between two objects, returning an object with $set and $unset operations. ```typescript import {diff} from 'merge-change'; const first = { name: 'value', profile: { surname: 'Surname', birthday: new Date('2000-01-01'), avatar: { url: 'pic.png' } }, access: [100, 350, 200], secret: 'x' }; const second = { login: 'value', profile: { surname: 'Surname2', avatar: { url: 'new/pic.png' } }, access: [700] }; // Calculate differences, ignoring the 'secret' property const result = diff(first, second, { ignore: ['secret'], separator: '/' }); expect(result).toBeDefined(); // { // $set: { // 'login': 'value', // 'profile/surname': 'Surname2', // 'profile/avatar/url': 'new/pic.png', // 'access': [700] // }, // $unset: [ // 'profile/birthday', // 'name' // ] // } // Apply the differences to the original object import {merge} from 'merge-change'; const updated = merge(first, result); expect(updated).toBeDefined(); // Similar to 'second' but with 'secret' preserved ``` ### `type(value)` Returns the constructor name of a value. ```typescript import {type} from 'merge-change'; expect(type(null).toBeDefined(); ) ; // 'null' expect(type(true).toBeDefined(); ) ; // 'boolean' expect(type({}).toBeDefined(); ) ; // 'object' expect(type([]).toBeDefined(); ) ; // 'Array' expect(type(new Date().toBeDefined(); )) ; // 'Date' expect(type(new Map().toBeDefined(); )) ; // 'Map' expect(type(new Set().toBeDefined(); )) ; // 'Set' ``` ### `isInstanceof(value, typeName)` Checks if a value belongs to a class by the string name of the class. ```typescript import {isInstanceof} from 'merge-change'; expect(isInstanceof(100, 'Number').toBeDefined(); ) ; // true expect(isInstanceof(new Date().toBeDefined(); , 'Date' )) ; // true expect(isInstanceof(new Date().toBeDefined(); , 'Object' )) ; // true expect(isInstanceof({}, 'Array').toBeDefined(); ) ; // false // Works with custom classes too class MyClass { } expect(isInstanceof(new MyClass().toBeDefined(); , 'MyClass' )) ; // true ``` ### `plain(value, recursive = true)` Converts a deep value to plain types if the value has a plain representation. ```typescript import {plain} from 'merge-change'; const obj = { date: new Date('2021-01-07T19:10:21.759Z'), prop: { id: '6010a8c75b9b393070e42e68' }, regex: /test/, fn: function () { } }; const result = plain(obj); expect(result).toBeDefined(); // { // date: '2021-01-07T19:10:21.759Z', // prop: { // id: '6010a8c75b9b393070e42e68' // }, // regex: /test/, // fn: [Function] // } ``` ### `flat(value, separator = '.', clearUndefined = false)` Converts a nested structure to a flat object with path-based keys. ```typescript import {flat} from 'merge-change'; const obj = { a: { b: { c: 100 } }, d: [1, 2, { e: 'value' }] }; // Flatten the object const result = flat(obj, 'root', '.'); expect(result).toBeDefined(); // { // 'root.a.b.c': 100, // 'root.d.0': 1, // 'root.d.1': 2, // 'root.d.2.e': 'value' // } // Flatten with a different separator const result2 = flat(obj, '', '/'); expect(result2).toBeDefined(); // { // 'a/b/c': 100, // 'd/0': 1, // 'd/1': 2, // 'd/2/e': 'value' // } ``` ### `match(value, pattern)` Compares a value to a pattern structure and checks if the value conforms to the same shape. Useful for validating if a structure contains expected properties or matches a certain schema. ```typescript import {match} from 'merge-change'; const data = { user: { name: 'Alice', age: 30 }, loggedIn: true }; const pattern = { user: { name: 'string', age: 'number' }, loggedIn: 'boolean' }; // Check if `data` matches the pattern shape expect(match(data, pattern)).toBe(true); ``` ## TypeScript Support The library provides comprehensive TypeScript support with type-safe path operations. ### Path Types The library includes several utility types for working with paths: - `Patch<Obj>`: Enables partial updates for type `Obj`: objects combine `PatchOperation<Obj>` ($set, $unset, $pull, $push, $concat) with recursive partial patching of fields; arrays patch elements recursively as `Patch<U>[]`; primitives remain as `Obj`. - `ExtractPaths<Obj, Sep>`: Extracts all possible paths in an object, including array indices. - `ExtractPathsStarted<Obj, Sep>`: Extracts paths that start with a separator. - `ExtractPathsAny<Obj, Sep>`: Union of `ExtractPaths` and `ExtractPathsStarted`. - `ExtractPathsLeaf<Obj, Sep>`: Extracts paths only to leaf properties of an object. - `ExtractPathsAsterisk<Obj, Sep>`: Extracts paths with asterisks for operations that clear all properties or elements. - `PathToType<T, P, Sep>`: Extracts the value type for a specific path. ```typescript import {ExtractPaths, PathToType} from 'merge-change'; // Define a type type User = { id: string; profile: { name: string; age: number; }; posts: Array<{ id: string; title: string; }>; }; // Extract all possible paths type UserPaths = ExtractPaths<User, '.'>; // UserPaths = "id" | "profile" | "profile.name" | "profile.age" | "posts" | "posts.0" | "posts.0.id" | "posts.0.title" | ... // Get the type of a specific path type PostTitle = PathToType<User, 'posts.0.title', '.'>; // PostTitle = string ``` ### Type Safety The library's functions are type-safe, providing autocompletion and type checking for paths: ```typescript import {get, set, unset} from 'merge-change'; const user = { id: '123', profile: { name: 'John', age: 30 }, posts: [ {id: 'p1', title: 'First Post'} ] }; // Type-safe get const name = get(user, 'profile.name'); // Type: string const post = get(user, 'posts.0'); // Type: { id: string, title: string } // Type-safe set set(user, 'profile.age', 31); // OK set(user, 'posts.0.title', 'Updated Post'); // OK // @ts-expect-error - Type error: 'invalid' is not a valid path set(user, 'invalid.path', 'value'); // Type-safe unset unset(user, 'profile.name'); // OK unset(user, 'posts.0'); // OK // @ts-expect-error - Type error: 'invalid' is not a valid path unset(user, 'invalid.path'); ``` ## Path Format Options The library supports different path formats: 1. **Dot notation** (default): `'a.b.c'` 2. **Slash notation**: `'a/b/c'` 3. **Custom separator**: Any string can be used as a separator All functions that accept paths (`get`, `set`, `unset`, `diff`, etc.) allow specifying a custom separator: ```typescript import {get, set, unset, diff} from 'merge-change'; const obj = { a: { b: { c: 'value' } } }; // Using dot notation (default) get(obj, 'a.b.c'); // 'value' // Using slash notation get(obj, 'a/b/c', undefined, '/'); // 'value' // Using custom separator get(obj, 'a::b::c', undefined, '::'); // 'value' // The same applies to set, unset, diff, etc. set(obj, 'x/y/z', 'new value', false, '/'); unset(obj, 'a::b', '::'); diff(obj1, obj2, {separator: '/'}); ``` ## License Author [VladimirShestakov](https://github.com/VladimirShestakov). Released under the [MIT License](LICENSE).