README
JSON Immutability Helper
JSON-serialisable mutability helpers.
Originally based on
immutability-helper
,
with list, string and mathematical commands added, but now uses an
alternative syntax.
Install dependency
npm install --save json-immutability-helper
Motivation
When working with collaborative state shared over a network, it can be desirable to share state deltas rather than full state objects. This allows parallel editing from different editors, and reduces bandwidth requirements.
Sharing functions between browsers and servers is not desirable, as it introduces security concerns. Instead, this package provides a foundational set of primitive operations which cover typical mutations.
Because the operations are intended to be shared, all inputs are assumed to be potentially malicious, with necessary mitigations applied.
Usage
The core function update
takes an object and a spec, and returns an
updated object. The input object and spec are unchanged (considered
immutable).
Optimisations ensure that if any properties within the object are unchanged, they will be returned exactly (not a copy). This helps when detecting changes using shallow comparison.
Simple usage:
const { update } = require('json-immutability-helper');
const initialState = { foo: 3 };
const updatedState = update(initialState, { foo: ['add', 1] });
// updatedState = { foo: 4 }
// initialState is unchanged
You can define arbitrary hierarchies:
const { update } = require('json-immutability-helper');
const initialState = { foo: { bar: { baz: 1 } } };
const updatedState = update(initialState, {
foo: {
bar: {
baz: ['set', 7],
},
extra: ['set', 1]
},
});
// updatedState = { foo: { bar: { baz: 7 }, extra: 1 } }
Note that arrays in the spec define commands. To navigate to a particular item in an array, use a number as an object key:
const { update } = require('json-immutability-helper');
const initialState = { foo: [2, 8] };
const updatedState = update(initialState, {
foo: {
0: ['set', 5],
},
});
// updatedState = { foo: [5, 8] }
// To serialise as JSON, you can quote the index:
const spec = `
{
"foo": {
"0": ["set", 5]
}
}
`;
With list commands (note .with(listCommands)
):
const listCommands = require('json-immutability-helper/commands/list');
const { update } = require('json-immutability-helper').with(listCommands);
const initialState = {
items: [
{ myId: 3, myThing: 'this' },
{ myId: 28, myThing: 'that' },
],
};
// Change the property 'myThing' of the item with myId=3
const updatedState = update(initialState, {
items: [
'updateWhere',
['myId', 3],
{ myThing: ['set', 'updated this'] },
],
});
// Note that the spec is fully JSON-serialisable:
const spec = `
{
"items": [
"updateWhere",
["myId", 3],
{ "myThing": ["set", "updated this"] }
]
}
`;
const updatedJsonState = update(initialState, JSON.parse(spec));
Conditions
The main concept introduced in this project is conditions. Several
commands use conditions to decide which items to update, such as
updateIf
, updateWhere
and deleteFirstWhere
, among others.
Examples
const items = [1, 2, 3, 4];
// Set items to 5 using the condition {equals: 3}
const updatedItems = update(items, [
'updateWhere',
{ equals: 3 },
['set', 5],
]);
// The output is:
const expectedItems = [1, 2, 5, 4];
equals
checks for a specific value. Other conditions are available,
for example:
const items = [1, 2, 3, 4];
const updatedItems = update(items, [
'updateWhere',
{ greaterThanOrEqual: 3 },
['set', 5],
]);
// The output is:
const expectedItems = [1, 2, 5, 5];
Conditions can also be combined, for example to make a range:
const items = [1, 2, 3, 4];
const updatedItems = update(items, [
'updateWhere',
{ greaterThan: 1, lessThan: 4 },
['set', 5],
]);
// The output is:
const expectedItems = [1, 5, 5, 4];
Working with objects
A common use-case is working with lists of objects. In this
situation, you can provide a key
to test a particular property of
the object:
const items = [
{ myId: 3, myThing: 'this' },
{ myId: 28, myThing: 'that' },
];
const updatedItems = update(items, [
'updateWhere',
{ key: 'myId', equals: 3 },
{ myThing: ['set': 'updated this'] },
]);
// The output is:
const expectedItems = [
{ myId: 3, myThing: 'updated this' }, // <-- this item has changed
{ myId: 28, myThing: 'that' },
];
Because this is a common use-case, a shorthand is available:
['myProperty', myValue]
// Same as:
{key: 'myProperty', equals: myValue}
In both cases, this will look at the value of myProperty
in the
current object, and check if it is equal to myValue
.
Conditions can check multiple properties by wrapping them in an array (these only match if all conditions are met):
[
{key: 'myProperty', greaterThanOrEqual: 2},
{key: 'myOtherProperty', equals: 'something'},
]
// or, using the shorthand:
[
{key: 'myProperty', greaterThanOrEqual: 2},
['myOtherProperty', 'something'],
]
Condition reference
equals
: checks for an exact match. Note that this uses===
matching, sonull
andundefined
are distinct, and objects / arrays will never compare equal (only test primitive types).not
: negated form ofequals
.greaterThan
: checks for strictly-greater-than.greaterThanOrEqual
: checks for greater-than-or-equal.lessThan
: checks for strictly-less-than.lessThanOrEqual
: checks for less-than-or-equal.notNullish
: this checks fornull
orundefined
. This is the default if a key is provided but no checks.
You can add new conditions with:
const modifiedUpdate = update.with({
conditions: {
conditionName: (parameter) => (actualValue) => {
// parameter is the value assigned to the condition
// actualValue is the value of the object being tested
// example: greaterThan
return actualValue > parameter;
},
},
});
Commands reference
Generic
['set', value]
alias['=', value]
sets the value to the literal value given.['unset']
deletes the value. If the parent is an object, the property is removed. If the parent is an array, the element is removed and subsequent items re-packed. If used on the root,update
will returnundefined
(unlessallowUnset
is specified).['init', value]
if undefined, sets the value to the literal value given. Otherwise leaves the value unchanged.['updateIf', condition, spec, elseSpec?]
applies the givenspec
if thecondition
matches, otherwise applies theelseSpec
(if provided) or does nothing.['seq', specs...]
applies the givenspec
s sequentially. This can be used to create complex updates out of simple operations.// Computes (value + 2 - 10) const updated = update(state, [ 'seq', ['add', 2], ['subtract', 10], ]);
(note that for mathematical operations it is usually better to use
rpn
, described below).
Object
['merge', object, initial?]
Merges the keys of object into the current target. Similar to callingObject.assign
. Ifinitial
is provided and the target value is undefined, it will be assigned the value ofinitial
before merging (equivalent to['seq', ['init', initial], ['merge', object]]
).
Boolean
['toggle']
alias['~']
toggles the current value (true
→false
;false
→true
).
List
Use .with(listCommands)
to enable these commands.
['push', items...]
inserts one or more items at the end of the array.['unshift', items...]
inserts one or more items at the start of the array.['splice', arguments...]
invokessplice
on the array repeatedly. Each argument should be an array of parameters to send thesplice
function;[offset, length, items...]
. Note that offset can be negative to count from the end of the array.['insertBeforeFirstWhere', condition, items...]
inserts one or more items before the first item which matches thecondition
(or at the end of the array if no items match).['insertAfterFirstWhere', condition, items...]
inserts one or more items after the first item which matches thecondition
(or at the end of the array if no items match).['insertBeforeLastWhere', condition, items...]
inserts one or more items before the last item which matches thecondition
(or at the start of the array if no items match).['insertAfterLastWhere', condition, items...]
inserts one or more items after the last item which matches thecondition
(or at the start of the array if no items match).['updateAll', spec]
applies the givenspec
to all items in the array individually.['updateWhere', condition, spec]
applies the givenspec
to all items in the array which match thecondition
. If no item matches, does nothing. Same as['updateAll', ['updateIf', condition, spec]]
.['updateFirstWhere', condition, spec]
applies the givenspec
to the first item in the array which matches thecondition
. If no item matches, does nothing.['updateLastWhere', condition, spec]
applies the givenspec
to the last item in the array which matches thecondition
. If no item matches, does nothing.['deleteWhere', condition]
deletes all items in the array which match thecondition
. If no items match, does nothing. Same as['updateWhere', condition, ['unset']]
.['deleteFirstWhere', condition]
deletes the first item in the array which match thecondition
. If no item matches, does nothing. Same as['updateFirstWhere', condition, ['unset']]
.['deleteLastWhere', condition]
deletes the last item in the array which match thecondition
. If no item matches, does nothing. Same as['updateLastWhere', condition, ['unset']]
.
String
Use .with(stringCommands)
to enable these commands.
['replaceAll', search, replace]
replaces all occurrences ofsearch
in the string withreplace
. Note that thesearch
is used as a literal, not as a regular expression.['rpn', operations...]
reverse Polish notation command; see below for details.
Number
['add', value]
alias['+', value]
adds the givenvalue
to the current value. This is available in the default command set and does not need an extension.['subtract', value]
alias['-', value]
subtracts the givenvalue
from the current value. This is available in the default command set and does not need an extension.
Use .with(mathCommands)
to enable the following
command:
['rpn', operations...]
reverse Polish notation command; see below for details.
rpn
The rpn
command lets you specify updates in reverse Polish
notation. This is especially useful for applying complex mathematical
operations or string manipulations.
Some examples:
// compute x * 2
update(5, ['rpn', 'x', 2, '*']); // = 10
// compute x * 2 + 10
update(5, ['rpn', 'x', 2, '*', 10, '+']); // = 20
// compute sin(x) + 2 * cos(x)
update(5, ['rpn', 'x', 'sin', 2, 'x', 'cos', '*', '+']); // ~= -0.3916
String manipulation is also supported, but should only be enabled if
needed due to its ability to construct large strings, which could be
used by malicious clients to launch memory exhaustion attacks against
a server. To enable string manipulation, use .with(stringCommands)
:
const stringCommands = require('json-immutability-helper/commands/string');
const { update } = require('json-immutability-helper').with(stringCommands);
// compute x.substr(4, 3)
update('foo bar baz', ['rpn', 'x', 4, 3, 'substr']); // = bar
// compute x.leftPad(2)
update('3', ['rpn', 'x', 2, 'leftPad']); // = 03
Some functions accept optional parameters or are variadic. By default
it is assumed that the minimum number of parameters are passed to
each function. To specify a different number, add :<num>
to the end
of the function name:
// compute max(x, -x, 2)
update(4, ['rpn', 'x', 'x', 'neg', 2, 'max:3']); // = 4
// compute log_2(x)
update(8, ['rpn', 'x', 2, 'log:2']); // = 3
String literals can be specified as JSON-encoded strings (this means
that for transport, strings may be double-encoded).
For example: '"foo"'
.
Available constants:
x
: the old valuepi
: the mathematical constant π (3.141…)e
: the mathematical constant e (2.718…)Inf
: positive infinityNaN
: not-a-number
Available functions/operators from mathCommands
:
value 'Number'
: converts the value to a numbera b '+'
: adds two numbersa b c ... '+:n'
: adds many numbersa b '-'
: subtractsb
froma
a b '*'
: multiplies two numbersa b '/'
: dividesa
byb
a b '//'
: dividesa
byb
, returning the truncated integer resulta b '^'
: raisesa
to the power ofb
a b '%'
: returns the remainder ofa / b
(can be negative)a b 'mod'
: returns the positive remainder ofa / b
a 'neg'
: negatesa
a 'abs'
: returns the absolute value ofa
value 'log'
: returns the natural logarithm ofvalue
value base 'log:2'
: returns the logarithm ofvalue
in basebase
value 'log2'
: returns the logarithm ofvalue
in base 2 (same asvalue 2 'log:2'
)value 'log10'
: returns the logarithm ofvalue
in base 10 (same asvalue 10 'log:2'
)value 'exp'
: returns the exponent ofvalue
(i.e.e^value
)value base 'exp:2'
: returnsbase^value
v1 v2 v3 ... 'max:n'
: returns the largest value (the default arity is 2)v1 v2 v3 ... 'min:n'
: returns the smallest value (the default arity is 2)a b 'bitor'
: returns the bitwise-or ofa
andb
a b 'bitand'
: returns the bitwise-and ofa
andb
a b 'bitxor'
: returns the bitwise-xor ofa
andb
a 'bitneg'
: returns the bitwise negation ofa
x 'sin'
: returnssin(x)
in radiansx 'cos'
: returnscos(x)
in radiansx 'tan'
: returnstan(x)
in radiansx 'asin'
: returnsasin(x)
in radiansx 'acos'
: returnsacos(x)
in radiansx 'atan'
: returnsatan(x)
in radiansx 'sinh'
: returnssinh(x)
x 'cosh'
: returnscosh(x)
x 'tanh'
: returnstanh(x)
x 'asinh'
: returnsasinh(x)
x 'acosh'
: returnsacosh(x)
x 'atanh'
: returnsatanh(x)
x 'round'
: roundsx
to the nearest integer ("round halves-up") (for control over the number of decimal places, see'String'
)x 'floor'
: returns the highest integer which is less than or equal tox
("round down")x 'ceil'
: returns the lowest integer which is greater than or equal tox
("round up")x 'trunc'
: returns the largest integer with absolute value less than or equal toabs(x)
("round towards zero")
Available functions/operators from stringCommands
:
value 'String'
: converts the value to a stringvalue dp 'String:2'
: converts the value to a rounded string (decimal places can be a positive or negative integer)string 'length'
: returns the length ofstring
in charactersa b c ... 'concat:n'
: concatenates strings (the default arity is 2)string count 'repeat'
: repeatsstring
count
timesstring search 'indexOf'
: returns the index of the first occurrence ofsearch
instring
(0-based), or -1 if it is not foundstring search start 'indexOf:3'
: returns the index of the first occurrence ofsearch
instring
, skipping the firststart
charactersstring search 'lastIndexOf'
: returns the index of the last occurrence ofsearch
instring
(0-based), or -1 if it is not foundstring search end 'lastIndexOf:3'
: returns the index of the last occurrence ofsearch
instring
within the range up toend
string length 'padStart'
: pads the start ofstring
with spaces until it is at leastlength
characters longstring length padding 'padStart:3'
: pads the start ofstring
withpadding
until it is at leastlength
characters long (fragments of thepadding
string may be used)string length 'padEnd'
: pads the end ofstring
with spaces until it is at leastlength
characters longstring length padding 'padEnd:3'
: pads the end ofstring
withpadding
until it is at leastlength
characters long (fragments of thepadding
string may be used)string from 'slice'
: returns a substring ofstring
fromfrom
to the end of the string. Iffrom
is negative, it counts from the end of the string.string from to 'slice:3'
: returns a substring ofstring
fromfrom
toto
(exclusive). Iffrom
orto
are negative, they count from the end of the string.string from length 'substr'
: returns a substring ofstring
fromfrom
of lengthlength
. Iffrom
is negative it counts from the end of the string.
As a basic protection against memory exhaution attacks, the generated
string length for all operations is capped to 1024 characters, and
String
only accepts decimal places within the range -20 – 20.
These restrictions ensure that memory usage can only be linear in the
number of operations, but could still become very high. As the risk
cannot be fully mitigated, string operations are disabled by default
and must be explicitly enabled by using stringCommands
.
Other context methods
You can access the default context, or create your own scoped context:
const defaultContext = require('json-immutability-helper');
const defaultContext = require('json-immutability-helper');
const myContext = defaultContext.with(/* extensions here */);
.with(...extensions)
returns a new context which copies the current context with the given extensions added. Does not mutate the current context. If called with no extensions, this just makes a copy of the current context, though this is generally not useful (the context is immutable anyway).Extensions can be:
listCommands
:require('json-immutability-helper/commands/list')
mathCommands
:require('json-immutability-helper/commands/math')
stringCommands
:require('json-immutability-helper/commands/string')
Or a custom extension (all fields are optional; any omitted field is left unchanged):
const myExtension = { commands: { myCommand: (object, args, context) => newValue, /* etc. */ }, conditions: { myCondition: (param) => (actual) => boolean, /* etc. */ }, limits: { stringLength: 1024, recursionDepth: 10, recursionBreadth: 10000, }, isEquals: (x, y) => (x === y), copy: (o) => myCopyFunction(o), }
The
commands
section defines new commands which can be used in the same places as built-in commands.object
is the previous value for the current position.args
is an array of parameters (excluding the command name).context
is an object which contains the methods listed here, as well asupdate
.The
conditions
section defines new conditions which can be used in the same places as built-in conditions.The
limits
are used by various built-in commands to ensure resource usage does not grow too high. You can omit the entire section or individual entries to leave the defaults, or specify higher or lower limits.isEquals
is the function used internally to determine whether a command caused a value to change (if this returnsfalse
, the original value will be used rather than the new value)copy
is the function used internally to make shallow copies of data structures. The default implementation can clone objects, arrays, and primitive values.Note that as a convenience it is also possible to call
.with
on theupdate
function itself. This does the same thing, but returns a newupdate
function rather than a newcontext
(equivalent to callingupdate.context.with(...).update
)..combine(specs)
generates a single spec which is equivalent to applying all the given specs sequentially. Conceptually this is identical to using['seq', ...specs]
, butcombine
optimises common paths where possible. Note that the specs must be provided in an array, not as variadic parameters..makeConditionPredicate(condition)
generates a predicate for the provided condition. This should be used by custom commands when filtering based on a provided condition is required..invariant(check, message?)
throws an exception ifcheck
is false. Includes the message if specified (can be a string or a function which returns a string).
The default context's update
, combine
and invariant
are also
available as direct imports:
const { update, combine, invariant } = require('json-immutability-helper');
Extending with .with()
By default, json-immutability-helper
exposes minimal commands for
reduced code size and increased security. If you need additional
commands, you can add built-in extensions (see the command list above
to see which commands need which extensions). Note that if you do not
need a particular extension you should not enable it, as all of these
have tradeoffs with bundle size and potential attacks (e.g. resource
exhaustion by generating large strings).
const listCommands = require('json-immutability-helper/commands/list');
const mathCommands = require('json-immutability-helper/commands/math');
const stringCommands = require('json-immutability-helper/commands/string');
const { update } = require('json-immutability-helper').with(listCommands, mathCommands, stringCommands);
or with ES6 imports:
import listCommands from 'json-immutability-helper/commands/list';
import mathCommands from 'json-immutability-helper/commands/math';
import stringCommands from 'json-immutability-helper/commands/string';
import context from 'json-immutability-helper';
const { update } = context.with(listCommands, mathCommands, stringCommands);
Avoid calling .with
inside functions or in loops. Ideally it should
be called once, and the resulting update
function can be called
many times.