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
Markdown
# 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).