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
797 lines (632 loc) • 17.4 kB
Markdown
# merge-change
A powerful TypeScript library for deep merging, patching, and immutable updates of data structures.
The library provides a simple yet flexible API for complex data transformations, with support for
declarative operations, custom type merging rules, and difference tracking.
## 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)
- [get](#get)
- [set](#set-1)
- [unset](#unset-1)
- [diff](#diff)
- [type](#type)
- [isInstanceof](#isinstanceof)
- [plain](#plain)
- [flat](#flat)
- [TypeScript Support](#typescript-support)
- [Path Types](#path-types)
- [Type Safety](#type-safety)
- [Path Format Options](#path-format-options)
- [License](#license)
## Installation
Install with npm:
```sh
npm install --save merge-change
```
## Core Functions
### merge
Creates a new object by deeply merging source objects without modifying them. This is ideal for
creating or extending objects from a template.
```typescript
import {merge} from 'merge-change';
// Create a new object with added properties and removed properties
const first = {
a: {
one: true,
two: 2
}
};
const second = {
a: {
three: 3,
$unset: ['one'] // Declarative operation to remove 'one'
}
};
const result = merge(first, second);
console.log(result); // { a: { two: 2, three: 3 } }
// Original objects remain unchanged
console.log(first); // { a: { one: true, two: 2 } }
console.log(second); // { a: { three: 3, $unset: ['one'] } }
```
### update
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 first = {
a: {
one: true,
two: 2,
sub: {
value: 3
}
}
};
const second = {
a: {
three: 3,
$unset: ['one'] // Declarative operation to remove 'one'
}
};
const result = update(first, second);
console.log(result); // { a: { two: 2, three: 3, sub: { value: 3 } } }
// Result is a new object
console.log(result !== first); // true
console.log(result !== second); // true
// Unchanged nested objects maintain reference equality
console.log(result.a.sub === first.a.sub); // true
```
### patch
Merges objects by mutating the source object. This is useful for patching existing objects without
creating new instances.
```typescript
import {patch} from 'merge-change';
const first = {
a: {
one: true,
two: 2
}
};
const second = {
a: {
three: 3,
$unset: ['one'] // Declarative operation to remove 'one'
}
};
const result = patch(first, second);
console.log(result); // { a: { two: 2, three: 3 } }
// Result is the same object as first (mutated)
console.log(result === first); // true
console.log(result !== second); // true
```
## Declarative Operations
Declarative operations allow you to perform specific actions during merging, such as deleting
properties, replacing objects, or concatenating arrays.
### $set
Sets or replaces properties without deep merging.
```typescript
import {merge} from 'merge-change';
const result = merge(
{
a: {
one: 1,
two: 2
}
},
{
$set: {
a: {
three: 3
},
'a.two': 20 // Property keys can be paths
}
}
);
console.log(result);
// {
// a: {
// one: 1,
// two: 20,
// three: 3
// }
// }
```
### $unset
Removes properties by name or path.
```typescript
import {merge} from 'merge-change';
const result = merge(
{
a: {
one: 1,
two: 2
}
},
{
$unset: ['a.two']
}
);
console.log(result);
// {
// a: {
// one: 1
// }
// }
```
You can use the asterisk (`*`) to remove all properties:
```typescript
import {merge} from 'merge-change';
const result = merge(
{
a: {
one: 1,
two: 2
}
},
{
$unset: ['a.*']
}
);
console.log(result);
// {
// 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']
}
}
);
console.log(result);
// {
// a: {
// two: 2
// }
// }
```
### $push
Adds values to array properties.
```typescript
import {merge} from 'merge-change';
const result = merge(
{
prop1: ['a', 'b'],
prop2: ['a', 'b']
},
{
$push: {
prop1: ['c', 'd'],
prop2: {x: 'c'}
}
}
);
console.log(result);
// {
// prop1: ['a', 'b', ['c', 'd']],
// prop2: ['a', 'b', { x: 'c' }]
// }
```
### $concat
Concatenates arrays.
```typescript
import {merge} from 'merge-change';
const result = merge(
{
prop1: ['a', 'b'],
prop2: ['a', 'b']
},
{
$concat: {
prop1: ['c', 'd'],
prop2: {x: 'c'}
}
}
);
console.log(result);
// {
// prop1: ['a', 'b', 'c', 'd'],
// prop2: ['a', 'b', { x: 'c' }]
// }
```
### $pull
Removes elements from arrays by value equality.
```typescript
import {merge} from 'merge-change';
const result = merge(
{
items: [1, 2, 3, 2, 4]
},
{
$pull: {
items: 2
}
}
);
console.log(result);
// {
// 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 margin with any types
```typescript
import {createMerge, createUpdate, createPatch} from 'merge-change';
// Create custom merge methods that always concatenate arrays
const customMethods = {
// Method name is formed from the types: Array_Array
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]}
);
console.log(result); // { items: [1, 2, 3, 4] }
```
## Utility Functions
### get
Retrieves a value from a nested object using a 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');
console.log(value1); // 'value'
// Get an array element
const value2 = get(obj, 'a.items.1');
console.log(value2); // 2
// Get with a default value for non-existent paths
const value3 = get(obj, 'a.x.y', 'default');
console.log(value3); // 'default'
// Get with a custom separator
const value4 = get(obj, 'a/b/c', undefined, '/');
console.log(value4); // 'value'
```
### set
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');
console.log(obj); // { a: { b: { c: 'value' } } }
// Set with a custom separator
set(obj, 'a/b/d', 'another value', false, '/');
console.log(obj); // { a: { b: { c: 'value', d: 'another value' } } }
// Set only if the property doesn't exist
set(obj, 'a.b.c', 'new value', true);
console.log(obj); // { 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');
console.log(obj); // { a: { b: { c: 'value', d: 'another value' }, items: ['first', 'second'] } }
```
### unset
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');
console.log(obj); // { a: { b: { d: 'another value' }, items: [1, 2, 3] } }
// Remove an array element
unset(obj, 'a.items.1');
console.log(obj); // { a: { b: { d: 'another value' }, items: [1, 3] } }
// Remove all properties using asterisk
unset(obj, 'a.b.*');
console.log(obj); // { a: { b: {}, items: [1, 3] } }
// Remove with a custom separator
unset(obj, 'a/items', '/');
console.log(obj); // { a: { b: {} } }
```
### diff
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: '/'
});
console.log(result);
// {
// $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);
console.log(updated);
// Similar to 'second' but with 'secret' preserved
```
### type
Returns the constructor name of a value.
```typescript
import {type} from 'merge-change';
console.log(type(null)); // 'null'
console.log(type(true)); // 'boolean'
console.log(type({})); // 'object'
console.log(type([])); // 'Array'
console.log(type(new Date())); // 'Date'
console.log(type(new Map())); // 'Map'
console.log(type(new Set())); // 'Set'
```
### isInstanceof
Checks if a value belongs to a class by the string name of the class.
```typescript
import {isInstanceof} from 'merge-change';
console.log(isInstanceof(100, 'Number')); // true
console.log(isInstanceof(new Date(), 'Date')); // true
console.log(isInstanceof(new Date(), 'Object')); // true
console.log(isInstanceof({}, 'Array')); // false
// Works with custom classes too
class MyClass {
}
console.log(isInstanceof(new MyClass(), 'MyClass')); // true
```
### plain
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);
console.log(result);
// {
// date: '2021-01-07T19:10:21.759Z',
// prop: {
// id: '6010a8c75b9b393070e42e68'
// },
// regex: /test/,
// fn: [Function]
// }
```
### flat
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', '.');
console.log(result);
// {
// '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, '', '/');
console.log(result2);
// {
// 'a/b/c': 100,
// 'd/0': 1,
// 'd/1': 2,
// 'd/2/e': 'value'
// }
```
## 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).