What types, and what serialisation pattern, can I use to ensure I have arbitrary-precision numbers in my JavaScript program while it's running, but the state is serialised to / deserialised from JSON with a minimum of pain?
I am writing an application that makes heavy use of numbers with higher precision than allowed by the ECMAScript native Number
type. So I need to use a custom type (currently BigNumber
) to represent those values.
I am looking hungrily at the proposed BigInt
type, which when implemented as standard will be an improvement for many applications including the one I'm writing. However that won't help at all with JSON, which knows nothing about BigInt
any more than BigNumber
.
Whichever custom type I choose, there are many values of this type throughout the application state. So much that it's worth considering a custom hook in the serialisation / deserialisation layer that will handle transforming it to / from JSON.
Presumably the JSON document will have to represent the values as JSON native types (e.g. Object
or String
instances). How then, when de-serialising the entire complex application state, to reliably recognise and de-serialise those instances among all the others, to the correct value of the correct BigInt
or BigNumber
type?
How can I serialise that state, such that any BigNumber
(or insert some other arbitrary-precision number type) value reliably survives the serialisation / de-serialisation process, to correct values of the correct type?
If modifying the type (by adding a property to the prototype) is feasible, there is a toJSON
hook from JSON.stringify
, designed specifically to help custom types collaborate with JSON serialisation.
If an object being stringified has a property named
toJSON
whose value is a function, then thetoJSON()
method customizes JSON stringification behavior: instead of the object being serialized, the value returned by thetoJSON()
method when called will be serialized.
So you could add a new method to the BigNumber
class:
BigNumber.prototype.toJSON = function toJSON(key) {
return {
_type: 'BigNumber',
_data: Object.assign({}, this),
};
};
state = {
lorem: true,
ipsum: "Consecteur non dibale",
dolor: new BigNumber(107.58),
sit: { spam: 5, eggs: 6, beans: 7 },
amet: false,
};
serialisedState = JSON.stringify(state);
console.debug("serialisedState:", serialisedState);
// → '{"lorem":true,"ipsum":"Consecteur non dibale","dolor":{"_type":"BigNumber","_data":{"s":1,"e":2,"c":[1,0,7,5,8]}},"sit":{"spam":5,"eggs":6,"beans":7},"amet":false}'
You can then recognise those specific objects when de-serialising, using the reviver
parameter of JSON.parse
:
If a
reviver
is specified, the value computed by parsing is transformed before being returned. Specifically, the computed value and all its properties (beginning with the most nested properties and proceeding to the original value itself) are individually run through thereviver
. Then it is called, with the object containing the property being processed asthis
, and with the property name as a string, and the property value as arguments. [If the return value is notundefined
], the property is redefined to be the return value.
function reviveFromJSON(key, value) {
let result = value;
if (
(typeof value === 'object' && value !== null)
&& (value.hasOwnProperty('_type'))) {
switch (value._type) {
case 'BigNumber':
result = Object.assign(new BigNumber(0), value._data);
}
}
return result;
}
state = JSON.parse(serialisedState, reviveFromJSON);
console.debug("state:", state);
// → { … dolor: BigNumber { s: 1, e: 2, c: [ 1, 0, 7, 5, 8 ] }, … }