UNPKG

lossless-json

Version:

Parse JSON without risk of losing numeric information

476 lines (332 loc) 18.5 kB
# lossless-json Parse JSON without risk of losing numeric information. ```js import { parse, stringify } from 'lossless-json' const text = '{"decimal":2.370,"long":9123372036854000123,"big":2.3e+500}' // JSON.parse will lose some digits and a whole number: console.log(JSON.stringify(JSON.parse(text))) // '{"decimal":2.37,"long":9123372036854000000,"big":null}' // WHOOPS!!! // LosslessJSON.parse will preserve all numbers and even the formatting: console.log(stringify(parse(text))) // '{"decimal":2.370,"long":9123372036854000123,"big":2.3e+500}' ``` The following in-depth article explains what happens there: [Why does JSON.parse corrupt large numbers and how to solve this?](https://jsoneditoronline.org/indepth/parse/why-does-json-parse-corrupt-large-numbers/) **How does it work?** The library works exactly the same as the native `JSON.parse` and `JSON.stringify`. The difference is that `lossless-json` preserves information of big numbers. `lossless-json` parses numeric values not as a regular number but as a `LosslessNumber`, a lightweight class which stores the numeric value as a string. One can perform regular operations with a `LosslessNumber`, and it will throw an error when this would result in losing information. **When to use?** If you have to deal with JSON data that contains `long` values for example, coming from an application like C++, Java, or C#. The trade-off is that `lossless-json` is slower than the native `JSON.parse` and `JSON.stringify` functions, so be careful when performance is a bottleneck for you. Features: - No risk of losing numeric information when working with big numbers. - Maintain the formatting of numbers. - Parse error on duplicate keys. - Built-in support for `bigint`. - Built-in support for `Date` (turned off by default). - Customizable: parse numeric values into any data type, like `BigNumber`, `bigint`, `number`, or a mix of them. - Compatible with the native, built-in `JSON.parse` and `JSON.stringify`. - Helpful error messages when parsing invalid JSON. - Works in browsers and node.js. - Comes with TypeScript typings included. - Modular: ES module functions, only load and bundle what you use. - The full bundle is less than 4kB in size when minified and gzipped. ## Install Install via [npm](https://www.npmjs.com/package/lossless-json): ``` npm install lossless-json ``` ## Use ### Parse and stringify Parsing and stringification works as you're used to: ```js import { parse, stringify } from 'lossless-json' const json = parse('{"foo":"bar"}') // {foo: 'bar'} const text = stringify(json) // '{"foo":"bar"}' ``` ### LosslessNumbers Numbers are parsed into a `LosslessNumber`, which can be used like a regular number in numeric operations. Converting to a number will throw an error when this would result in losing information due to truncation, overflow, or underflow. ```js import { parse } from 'lossless-json' const text = '{"normal":2.3,"long":123456789012345678901,"big":2.3e+500}' const json = parse(text) console.log(json.normal.isLosslessNumber) // true console.log(json.normal.valueOf()) // number, 2.3 // LosslessNumbers can be used as regular numbers console.log(json.normal + 2) // number, 4.3 // but the following operation will throw an error as it would result in information loss console.log(json.long + 1) // throws Error: Cannot safely convert LosslessNumber to number: // "123456789012345678901" will be parsed as 123456789012345680000 and lose information ``` ### BigInt JavaScript natively supports `bigint`: big integers that can hold a large number of digits, instead of the about 15 digits that a regular `number` can hold. It is a typical use case to want to parse integer numbers into a `bigint`, and all other values into a regular `number`. This can be achieved with a custom `numberParser`: ```js import { parse, isInteger } from 'lossless-json' // parse integer values into a bigint, and use a regular number otherwise export function customNumberParser(value) { return isInteger(value) ? BigInt(value) : parseFloat(value) } const text = '[123456789123456789123456789, 2.3, 123]' const json = parse(text, null, customNumberParser) // output: // [ // 123456789123456789123456789n, // bigint // 2.3, // number // 123n // bigint // ] ``` You can adjust the logic to your liking, using utility functions like `isInteger`, `isNumber`, `isSafeNumber`. The number parser shown above is included in the library and is named `parseNumberAndBigInt`. ### Validate safe numbers If you want parse a json string into an object with regular numbers, but want to validate that no numeric information is lost, you write your own number parser and use `isSafeNumber` to validate the numbers: ```js import { parse, isSafeNumber } from 'lossless-json' function parseAndValidateNumber(value) { if (!isSafeNumber(value)) { throw new Error(`Cannot safely convert value '${value}' into a number`) } return parseFloat(value) } // will parse with success if all values can be represented with a number let json = parse('[1,2,3]', undefined, parseAndValidateNumber) console.log(json) // [1, 2, 3] (regular numbers) // will throw an error when some of the values are too large to represent correctly as number try { let json = parse('[1,2e+500,3]', undefined, parseAndValidateNumber) } catch (err) { console.log(err) // throws Error 'Cannot safely convert value '2e+500' into a number' } ``` ### BigNumbers To use the library in conjunction with your favorite BigNumber library, for example [decimal.js](https://github.com/MikeMcl/decimal.js/). You have to define a custom number parser and stringifier: ```js import { parse, stringify } from 'lossless-json' import Decimal from 'decimal.js' const parseDecimal = (value) => new Decimal(value) const decimalStringifier = { test: (value) => Decimal.isDecimal(value), stringify: (value) => value.toString() } // parse JSON, operate on a Decimal value, then stringify again const text = '{"value":2.3e500}' const json = parse(text, undefined, parseDecimal) // {value: new Decimal('2.3e500')} const output = { // {result: new Decimal('4.6e500')} result: json.value.times(2) } const str = stringify(output, undefined, undefined, [decimalStringifier]) // '{"result":4.6e500}' ``` ### Reviver and replacer The library is compatible with the native `JSON.parse` and `JSON.stringify`, and also comes with the optional `reviver` and `replacer` arguments that allow you to serialize for example data classes in a custom way. Here is an example demonstrating how you can stringify a `Date` in a different way than the built-in `reviveDate` utility function. The following example stringifies a `Date` as an object with a `$date` key instead of a string, so it is uniquely recognizable when parsing the structure: ```js import { parse, stringify } from 'lossless-json' // stringify a Date as a unique object with a key '$date', so it is recognizable function customDateReplacer(key, value) { if (value instanceof Date) { return { $date: value.toISOString() } } return value } function isJSONDateObject(value) { return value && typeof value === 'object' && typeof value.$date === 'string' } function customDateReviver(key, value) { if (isJSONDateObject(value)) { return new Date(value.$date) } return value } const record = { message: 'Hello World', timestamp: new Date('2022-08-30T09:00:00Z') } const text = stringify(record, customDateReplacer) console.log(text) // output: // '{"message":"Hello World","timestamp":{"$date":"2022-08-30T09:00:00.000Z"}}' const parsed = parse(text, customDateReviver) console.log(parsed) // output: // { // action: 'create', // timestamp: new Date('2022-08-30T09:00:00.000Z') // } ``` ## API ### parse(text [, reviver [, parseNumber]]) The `LosslessJSON.parse()` function parses a string as JSON, optionally transforming the value produced by parsing. - **@param** `{string} text` The string to parse as JSON. See the JSON object for a description of JSON syntax. - **@param** `{(key: string, value: unknown) => unknown} [reviver]` If a function, prescribes how the value originally produced by parsing is transformed, before being returned. - **@param** `{function(value: string) : unknown} [parseNumber]` Pass an optional custom number parser. Input is a string, and the output can be any numeric value: `number`, `bigint`, `LosslessNumber`, or a custom `BigNumber` library. By default, all numeric values are parsed into a `LosslessNumber`. - **@returns** `{unknown}` Returns the Object corresponding to the given JSON text. - **@throws** Throws a SyntaxError exception if the string to parse is not valid JSON. ### stringify(value [, replacer [, space [, numberStringifiers]]]) The `LosslessJSON.stringify()` function converts a JavaScript value to a JSON string, optionally replacing values if a replacer function is specified, or optionally including only the specified properties if a replacer array is specified. - **@param** `{unknown} value` The value to convert to a JSON string. - **@param** `{((key: string, value: unknown) => unknown) | Array.<string | number>} [replacer]` A function that alters the behavior of the stringification process, or an array with strings or numbers that serve as a whitelist for selecting the properties of the value object to be included in the JSON string. If this value is `null` or not provided, all properties of the object are included in the resulting JSON string. - **@param** `{number | string | undefined} [space]` A `string` or `number` that is used to insert white space into the output JSON string for readability purposes. If this is a `number`, it indicates the number of space characters to use as white space. Values less than 1 indicate that no space should be used. If this is a `string`, the `string` is used as white space. If this parameter is not provided (or is `null`), no white space is used. - **@param** `{Array<{test: (value: unknown) => boolean, stringify: (value: unknown) => string}>} [numberStringifiers]` An optional list with additional number stringifiers, for example to serialize a `BigNumber`. The output of the function must be valid stringified JSON number. When `undefined` is returned, the property will be deleted from the object. The difference with using a `replacer` is that the output of a `replacer` must be JSON and will be stringified afterwards, whereas the output of the `numberStringifiers` is already stringified JSON. - **@returns** `{string | undefined}` Returns the string representation of the JSON object. - **@throws** Throws a SyntaxError when one of the `numberStringifiers` does not return valid output. ### LosslessNumber #### Construction ``` new LosslessNumber(value: number | string) : LosslessNumber ``` #### Methods - `.valueOf(): number | bigint` Convert the `LosslessNumber` into a regular `number` or `bigint`. A `number` is returned for safe numbers and decimal values that only lose some insignificant digits. A `bigint` is returned for large integer numbers. An `Error` is thrown for values that will overflow or underflow. Examples: ```js // a safe number console.log(new LosslessNumber('23.4').valueOf()) // number 23.4 // a decimal losing insignificant digits console.log(new LosslessNumber('0.66666666666666666666667').valueOf()) // number 0.6666666666666666 // a large integer console.log(new LosslessNumber('9123372036854000123').valueOf()) // bigint 9123372036854000123 // a value that will overflow console.log(new LosslessNumber('2.3e+500').valueOf()) // Error: Cannot safely convert to number: the value '2.3e+500' would overflow and become Infinity // a value that will underflow console.log(new LosslessNumber('2.3e-500').valueOf()) // Error: Cannot safely convert to number: the value '2.3e-500' would underflow and become 0 ``` Note that you can implement your own strategy for conversion by just getting the value as string via `.toString()`, and using util functions like `isInteger`, `isSafeNumber`, `getUnsafeNumberReason`, and `toSafeNumberOrThrow` to convert it to a numeric value. - `.toString() : string` Get the string representation of the lossless number. #### Properties - `{boolean} .isLosslessNumber : true` Lossless numbers contain a property `isLosslessNumber` which can be used to check whether some variable contains LosslessNumber. ### Utility functions - `isInteger(value: string) : boolean` Test whether a string contains an integer value, like `'2300'` or `10`. - `isNumber(value: string) : boolean` Test whether a string contains a numeric value, like `'2.4'` or `'1.4e+3'`. - `isSafeNumber(value: string, config?: { approx: boolean }): boolean` Test whether a string contains a numeric value which can be safely represented by a JavaScript `number` without losing any information. Returns false when digits would be truncated of an integer or decimal, or when the number would overflow or underflow. When passing `{ approx: true }` as config, the function will be less strict and allow losing insignificant digits of a decimal value. Examples: ```js isSafeNumber('1.55e3') // true isSafeNumber('2e500') // false isSafeNumber('2e-500') // false isSafeNumber('9123372036854000123') // false isSafeNumber('0.66666666666666666667') // false isSafeNumber('9123372036854000123', { approx: true }) // false isSafeNumber('0.66666666666666666667', { approx: true }) // true ``` - `toSafeNumberOrThrow(value: string, config?: { approx: boolean }) : number` Convert a string into a number when it is safe to do so, otherwise throw an informative error. - `getUnsafeNumberReason(value): UnsafeNumberReason | undefined` When the provided `value` is an unsafe number, describe what the reason is: `overflow`, `underflow`, `truncate_integer`, `truncate_float`. Returns `undefined` when the value is safe. - `isLosslessNumber(value: unknown) : boolean` Test whether a value is a `LosslessNumber`. - `toLosslessNumber(value: number) : LosslessNumber` Convert a `number` into a `LosslessNumber`. The function will throw an exception when the `number` is exceeding the maximum safe limit of 15 digits (hence being truncated itself) or is `NaN` or `Infinity`. - `parseLosslessNumber(value: string) : LosslessNumber` The default `numberParser` used by `parse`. Creates a `LosslessNumber` from a string containing a numeric value. - `parseNumberAndBigInt(value: string) : number | bigint` A custom `numberParser` that can be used by `parse`. The parser will convert integer values into `bigint`, and converts al other values into a regular `number`. - `reviveDate(key, value)` Revive strings containing an ISO 8601 date string into a JavaScript `Date` object. This reviver is not turned on by default because there is a small risk of parsing a text field that _accidentally_ contains a date into a `Date`. Whether `reviveDate` is safe to use depends on the use case. Usage: ```js import { parse, reviveDate } from 'lossless-json' const data = parse('["2022-08-25T09:39:19.288Z"]', reviveDate) // output: // [ // new Date('2022-08-25T09:39:19.288Z') // ] ``` An alternative solution is to stringify a `Date` in a specific recognizable object like `{'$date':'2022-08-25T09:39:19.288Z'}`, and use a reviver and replacer to turn this object into a `Date` and vice versa. - `splitNumber(value: string) : { sign: '-' | '', digits: string, exponent: number }` Split a number in its sign, digits, and exponent. For example `splitNumber("23.50")` returns `{sign: '', digits: '235', exp: 1 }`. The value can be constructed again from a split number by inserting a dot at the second character of the digits if there is more than one digit, prepending it with the sign, and appending the exponent like `e${exponent}` - `compareNumber(a: string, b: string) : -1 | 0 | 1` Compare two strings containing a numeric value based on their numerical value. For example, the numeric value of `"5e3"` is larger than `"70"`, but comparing the string characters concludes otherwise. The function returns `1` when `a` is larger than `b`, `0` when they are equal, and `-1` when `a` is smaller than `b`. This method works safely for values with a large number of digits. - `compareLosslessNumber(a: LosslessNumber, b: LosslessNumber) : -1 | 0 | 1` Compare two lossless numbers numerically. The function returns `1` when `a` is larger than `b`, `0` when they are equal, and `-1` when `a` is smaller than `b`. The compare function can be used to sort an array with `LosslessNumber` for example: ```js import { LosslessNumber, compareLosslessNumber } from 'lossless-json' const values = [ new LosslessNumber('5e3'), new LosslessNumber('70'), new LosslessNumber('0.02e5') ] const sorted = values.toSorted(compareLosslessNumber) // sorted = [ // new LosslessNumber('70'), // new LosslessNumber('0.02e5'), // new LosslessNumber('5e3') //] ``` ## Alternatives Similar libraries: - https://github.com/jawj/json-custom-numbers - https://github.com/sidorares/json-bigint - https://github.com/nicolasparada/js-json-bigint - https://github.com/epoberezkin/json-source-map ## Test To test the library, first install dependencies once: ``` npm install ``` To run the unit tests: ``` npm test ``` To build the library and run the unit tests and integration tests: ``` npm run build-and-test ``` ## Lint Run linting: ``` npm run lint ``` Fix linting issues automatically: ``` npm run format ``` ## Benchmark To run a benchmark to compare the performance with the native `JSON` parser: ``` npm run benchmark ``` (Spoiler: `lossless-json` is much slower than native) ## Build To build a bundled and minified library (ES5), first install the dependencies once: ``` npm install ``` Then bundle the code: ``` npm run build ``` This will generate an ES module output and an UMD bundle in the folder `./.lib` which can be executed in browsers and node.js and used in the browser. ### Release To release a new version: ``` $ npm run release ``` This will: - lint - test - build - increment the version number - push the changes to git, add a git version tag - publish the npm package To try the build and see the change list without actually publishing: ``` $ npm run release-dry-run ``` ## License Released under the [MIT license](LICENSE.md).