Helpful Merge
A highly-configurable merge implementation with intelligent suggestion-based error handling for validating consumer-provided input against configuration interfaces.
For example, consider the following common scenario of a consumer misspelling properties on a configuration interface:
import merge from 'helpful-merge';
const defaults = Object.seal({
autoplay: false
volume: 0.85
});
const options = {
autoplays: true
};
merge(defaults, options);
As demonstrated above, Helpful Merge will throw an exception when invalid properties are provided by the consumer, providing the closest matching suggestion for the offending property:
TypeError: Unknown property "autoplays". Did you mean "autoplay"?
Helpful Merge is great for creating robust and helpful entry points for JavaScript libraries and APIs and also includes efficient and customizable implementations of deep recursive merge, array merge, and more.
Helpful merge is small at only 4KB bundled and minified (2KB gzipped).
Contents
Installation
Firstly, install the package using your package manager of choice.
npm install helpful-merge --save-dev
You may then import the merge function into your project's modules.
import merge from 'helpful-merge';
Usage
Helpful Merge can be used in place of any existing merge implementation such as ES6 Object.assign()
, lodash/underscore's merge()
, or jQuery's $.extend()
.
Unlike Object.assign()
however, Helpful Merge will only merge one source object into one target object at a time when using the standard calling syntax. This is because its third parameter is reserved for an optional configuration object (see Configuration Options).
If you would like to merge multiple source objects into a target object in a single operation, Helpful Merge provides an alternative "fluent" interface for doing this. See Fluent Interface.
Syntax
merge(target, source, options?);
| Name | Type | Description |
---|
@param | target | T extends any | The target object to merge into |
@param | source | any | The source object to merge properties from |
@param | options? | IConfig|boolean | An optional object of configuration options or true as a shorthand for {deep: true} |
@return | target | T | A reference to the updated target object |
Configuration Options
The merge()
function accepts an optional third parameter of configuration options with the following defaults:
{
deep: false,
arrayStrategy: ArrayStrategy.REPLACE,
errorMessage: Messages.MERGE_ERROR,
includeNonEnumerable: false,
includeReadOnly: false
preserveTypeIfTargetUnset: false,
useReferenceIfArray: false,
useReferenceIfTargetUnset: false,
}
Zero or more of these options can be passed to the merge function after the source object, as needed:
merge(target, source, {
deep: true,
useReferenceIfTargetUnset: true
});
The most commonly used configuration option deep
, can be provided in a shorthand form by simply passing true
as the third parameter, instead of an object:
merge(target, source, true);
Available Options
deep
An optional boolean dictating whether or not to perform a deep recursive merge. By default, only a simple shallow merge will be performed, and any properties in the source object with object or array values will be copied to the target by reference only.
This option may also be set using an alternative shorthand syntax whereby the value true
is passed as the third parameter instead of {deep: true}
.
Example 1: Shallow merge (default behavior)
const target = {};
const source = {
foo: {},
bar: false
};
merge(target, source);
console.log(target.foo);
assert.equal(target.foo, source.foo)
Example 2: Deep Merge
const target = {};
const source = {
foo: {},
bar: false
};
merge(target, source, {deep: true});
console.log(target.foo);
assert.equal(target.foo, source.foo)
arrayStrategy
Type | Default |
---|
'PUSH'|'REPLACE' | 'REPLACE' |
A string dictating the kind of array merge strategy to use when copying the values of one array into another. By default, arrays are merged using the 'REPLACE'
strategy, where each value in the source array overwrites the value of the same index in the target.
In certain configuration interfaces, where we are may wish to extend some base configuration with additional values, a 'PUSH'
strategy may be preferable. In this case, the values of the source array are pushed on to the target array, and no data is overwritten.
To avoid magic strings, the possible values are available via the exported ArrayStrategy
enum, via ArrayStrategy.PUSH
, and ArrayStrategy.REPLACE
.
Example 1: Replace strategy (default behavior)
const target = {
foo: ['Jim', 'Jane', 'Joe']
};
const source = {
foo: ['Bill', 'Bob']
};
merge(target, source, true);
console.log(target.foo);
Example 2: Push strategy
import merge, {ArrayStrategy} from 'helpful-merge';
const target = {
foo: ['Jim', 'Jane', 'Joe']
};
const source = {
foo: ['Bill', 'Bob']
};
merge(target, source, {
deep: true,
arrayStrategy: ArrayStrategy.PUSH
});
console.log(target.foo);
errorMessage
Type | Default |
---|
(offending: string, suggestion: string) => string | Message.MERGE_ERROR |
A optional function with which to override the default error message thrown when a consumer attempts to add undefined properties to a sealed or non-extensible target object.
The function receives two arguments, the key of the offending property, and a suggestion in the form of the key of a closely matching property on the target (if found). The function must return a string.
The default error message function is as follows:
(offender, suggestion = '') => {
return `Unknown property "${offender}"` + (suggestion ? `. Did you mean "${suggestion}"?` : '');
}
Example: Implementing a custom error message
const errorMessage = (offender, suggestion = '') => {
return `[MyLibrary] Invalid POST option "${offender}"` + (suggestion ? `. Maybe you you meant "${suggestion}"?` : '');
};
merge(target, source, {errorMessage});
includeNonEnumerable
An optional boolean dictating whether or not to copy non-enumerable properties on the source object to the target object.
Example 1: Skipping non-enumerable properties (default behavior)
const target = {};
const source = {};
Object.defineProperty(source, 'foo', {
value: 'Hello world!',
enumerable: false
});
merge(target, source);
console.log(source.foo);
console.log(target.foo);
Example 2: Including non-enumerable properties
const target = {};
const source = {};
Object.defineProperty(source, 'foo', {
value: 'Hello world!',
enumerable: false
});
merge(target, source, {
includeNonEnumerable: true
});
console.log(source.foo);
console.log(target.foo);
includeReadOnly
An optional boolean dictating whether or not to copy the values of "read-only" properties on the source object to the target object. Read only properties are defined as accessor properties with a "getter", but no "setter".
Typically these would be defined on both the source and target, negating the need to copy them, as their values would be equal on both objects for the same underlying data.
Example 1: Skipping read-only properties (default behavior)
const target = {};
const source = {
firstName: 'Jill',
lastName: 'Kay'
};
Object.defineProperty(source, 'fullName', {
get() {
return this.firstName + this.lastName
}
});
merge(target, source);
console.log(source.fullName);
console.log(target.fullName);
Example 2: Including read-only properties
const target = {};
const source = {
firstName: 'Jill',
lastName: 'Kay'
};
Object.defineProperty(source, 'fullName', {
get() {
return this.firstName + this.lastName
}
});
merge(target, source, {
includeReadOnly: true
});
console.log(source.fullName);
console.log(target.fullName);
preserveTypeIfTargetUnset
An optional boolean dictating whether or not to attempt preservation of custom types, when performing a deep merge into a null
(unset) target value.
For example, property source.foo
may be an instance of consumer class Foo
, and property target.foo
may be set to null
. By default in this scenario, the individual properties and values of source.foo
would be copied onto a new plain object ({}
) which would be assigned to target.foo
.
When preserveTypeIfTargetUnset
is set to true
, Helpful Merge will attempt to derive the custom type (Foo
) of source.foo
, assign a new instance of it to target.foo
, and then copy all values across.
This is particularly useful when read-only computed properties ("getters") are present on the source object and should be maintained on the target object.
Example 1: Deep copying into a nested unset target (default behavior)
class Foo {}
const source = {
foo: new Foo()
}
const target = {
foo: null
};
merge(target, source, true);
assert.isOk(target.foo);
assert.instanceOf(source.foo, Foo);
assert.instanceOf(target.foo, Foo);
Example 2: Deep copying into a nested unset target with type preservation
class Foo {}
const source = {
foo: new Foo()
}
const target = {
foo: null
};
merge(target, source, {
deep: true,
preserveTypeIfTargetUnset: true
});
assert.isOk(target.foo);
assert.instanceOf(source.foo, Foo);
assert.instanceOf(target.foo, Foo);
useReferenceIfArray
An optional boolean dictating whether or not to copy nested arrays by reference, when performing a deep merge.
If set to true
, the recursive merge will stop at any property who's value is an array, and copy it by reference to the target object.
This provides an efficient boundary between a defined configuration structure, and consumer-provided array values where reference will suffice, and avoids unnecessary recursion and enumeration.
Example 1: Deep copying nested arrays (default behavior)
const target = {};
const source = {
names: ['Joe', 'Jill', 'Jim']
};
merge(source, target, true);
console.log(target.names);
assert.equal(target.names, source.names);
Example 2: Copying nested arrays by reference
const target = {};
const source = {
names: ['Joe', 'Jill', 'Jim']
};
merge(source, target, {
deep: true,
useReferenceIfArray: true
});
console.log(target.names);
assert.equal(target.names, source.names);
useReferenceIfTargetUnset
An optional boolean dictating whether or not to copy nested objects or arrays by reference, when the following criteria are met:
- A deep merge is already being performed (via
deep: true
) - The property exists on the source object as a nested object or array
- The property does not exist on the target object, or is
null
on the target object.
If set to true
, the recursive merge will stop at these "leaf" properties and their values will be copied to the target object by reference only, rather than being recursively cloned.
This provides an efficient boundary between a defined configuration structure, and consumer-provided hash or typed values where a reference will suffice, and avoids unnecessary recursion and enumeration.
Example 1: Deep copying nested objects/arrays when unset on target (default behavior)
const target = {
foo: {}
};
const source = {
foo: {
bar: {}
}
};
merge(target, source, true);
console.log(target.foo.bar);
assert.equal(target.foo.bar, source.foo.bar);
Example 2: Copying nested objects/arrays by reference when unset on target
const target = {
foo: {}
};
const source = {
foo: {
bar: {}
}
};
merge(target, source, true);
console.log(target.foo.bar);
assert.equal(target.foo.bar, source.foo.bar);
Fluent Interface
Sometimes we may want to merge multiple source objects into a target object in a single operation for brevity, just as we can do with ES6 Object.assign()
.
When using the default calling syntax (merge()
), the third parameter is reversed for an optional configuration object, meaning we can only supply a single source object.
In order to accomodate multiple source objects, an alternative "fluent" syntax is provided whereby we can construct a merge operation by chaining together various semantic methods:
import merge from 'helpful-merge';
merge.from(source).to(target).exec();
merge.from(sourceA, sourceB, sourceC).to(target).exec();
merge.from(source).to(target).with({deep: true}).exec();
merge.with({deep: true}).to(target).from(source).exec();
const foo = merge.with(true).to(new Foo()).from({bar: 'baz'}).exec();
const clone = merge.from(source).exec();
The final .exec()
method must always be called in order to execute the operation and return the target reference.
Just as with Object.assign()
, multiple sources are merged from right to left.
All available configuration options may be supplied to .with()
.
Library Example
A common pattern in many libraries is to allow the consumer to provide an optional object of configuration options:
const player = new Player({
autoplay: true,
volume: 0.8
});
If we don't validate that input, the consumer is free to provide erroneous configuration options and debugging the resulting problems in behavior becomes hard.
By implementing a sealed configuration class internally with sensible defaults, and merging consumer provided input into it, we can catch erroneous configuration at the point of instantiation and provide developer feedback.
./Config.js
class Config {
constructor() {
this.autoplay = false;
this.volume = 0.8;
Object.seal(this);
}
}
./Player.js
import Config from './Config.js';
class Player {
constructor(options={}) {
this.config = new Config();
Object.assign(this.config, options);
}
}
When the consumer provides an option not defined in the config class, a type error will be thrown:
const player = new Player({autoPlay: false});
Unfortunately, this message is not particularly helpful for novice developers who may not understand the concept of extensibility, nor is it particularly helpful for developers without intellisense who are forced to debug at runtime, which depending on your library's target demographic, could be the majority of users. This is where Helpful Merge comes in.
We can replace Object.assign()
in the above example with Helpful Merge's merge()
implementation, which provides a helpful and customizable error message with a suggestion of the closest matching property name on the target object:
./Player.js
import merge from 'helpful-merge';
import Config from './Config';
class Player {
constructor(options={}) {
this.config = new Config();
merge(this.config, options);
}
}
Now the consumer will see the following error message:
const player = new Widget({autoPlay: false});
This provides an easy means of catching typos, incorrect casing, or API version mismatches, which in turn provides a great developer experience for consumers of your library or API.
Helpful Merge also allows us to easily customize this error message to further improve the developer experience for your library or API. For example:
© 2017 Patrick Kunka / KunkaLabs Ltd