Skip to content

Commit

Permalink
feat: add strict option
Browse files Browse the repository at this point in the history
This option allows to verify that the serialized output fully
reflects the input without loosing information.
  • Loading branch information
BridgeAR committed Sep 17, 2022
1 parent 8e2dc7b commit f001e74
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 28 deletions.
1 change: 1 addition & 0 deletions esm/wrapper.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface StringifyOptions {
deterministic?: boolean,
maximumBreadth?: number,
maximumDepth?: number,
strict?: boolean,
}

export namespace stringify {
Expand Down
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface StringifyOptions {
deterministic?: boolean,
maximumBreadth?: number,
maximumDepth?: number,
strict?: boolean,
}

export namespace stringify {
Expand Down
100 changes: 78 additions & 22 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict'

const { hasOwnProperty } = Object.prototype

const stringify = configure()

// @ts-expect-error
Expand All @@ -20,7 +22,7 @@ module.exports = stringify
// eslint-disable-next-line
const strEscapeSequencesRegExp = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]|[\ud800-\udbff](?![\udc00-\udfff])|(?:[^\ud800-\udbff]|^)[\udc00-\udfff]/
// eslint-disable-next-line
const strEscapeSequencesReplacer = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]|[\ud800-\udbff](?![\udc00-\udfff])|(?:[^\ud800-\udbff]|^)[\udc00-\udfff]/g
const strEscapeSequencesReplacer = new RegExp(strEscapeSequencesRegExp, 'g')

// Escaped special characters. Use empty strings to fill up unused entries.
const meta = [
Expand Down Expand Up @@ -69,13 +71,13 @@ function strEscape (str) {
last = i + 1
} else if (point >= 0xd800 && point <= 0xdfff) {
if (point <= 0xdbff && i + 1 < str.length) {
const point = str.charCodeAt(i + 1)
if (point >= 0xdc00 && point <= 0xdfff) {
const nextPoint = str.charCodeAt(i + 1)
if (nextPoint >= 0xdc00 && nextPoint <= 0xdfff) {
i++
continue
}
}
result += `${str.slice(last, i)}${`\\u${point.toString(16)}`}`
result += `${str.slice(last, i)}\\u${point.toString(16)}`
last = i + 1
}
}
Expand Down Expand Up @@ -105,7 +107,7 @@ const typedArrayPrototypeGetSymbolToStringTag =
Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(
Object.getPrototypeOf(
new Uint8Array()
new Int8Array()
)
),
Symbol.toStringTag
Expand All @@ -128,8 +130,8 @@ function stringifyTypedArray (array, separator, maximumBreadth) {
}

function getCircularValueOption (options) {
if (options && Object.prototype.hasOwnProperty.call(options, 'circularValue')) {
var circularValue = options.circularValue
if (options && hasOwnProperty.call(options, 'circularValue')) {
const circularValue = options.circularValue
if (typeof circularValue === 'string') {
return `"${circularValue}"`
}
Expand All @@ -149,8 +151,9 @@ function getCircularValueOption (options) {
}

function getBooleanOption (options, key) {
if (options && Object.prototype.hasOwnProperty.call(options, key)) {
var value = options[key]
let value
if (options && hasOwnProperty.call(options, key)) {
value = options[key]
if (typeof value !== 'boolean') {
throw new TypeError(`The "${key}" argument must be of type boolean`)
}
Expand All @@ -159,8 +162,9 @@ function getBooleanOption (options, key) {
}

function getPositiveIntegerOption (options, key) {
if (options && Object.prototype.hasOwnProperty.call(options, key)) {
var value = options[key]
let value
if (options && hasOwnProperty.call(options, key)) {
value = options[key]
if (typeof value !== 'number') {
throw new TypeError(`The "${key}" argument must be of type number`)
}
Expand All @@ -184,16 +188,40 @@ function getItemCount (number) {
function getUniqueReplacerSet (replacerArray) {
const replacerSet = new Set()
for (const value of replacerArray) {
if (typeof value === 'string') {
replacerSet.add(value)
} else if (typeof value === 'number') {
if (typeof value === 'string' || typeof value === 'number') {
replacerSet.add(String(value))
}
}
return replacerSet
}

function getStrictOption (options) {
if (options && hasOwnProperty.call(options, 'strict')) {
const value = options.strict
if (typeof value !== 'boolean') {
throw new TypeError('The "strict" argument must be of type boolean')
}
if (value) {
return (value) => {
let message = `Object can not safely be stringified. Received type ${typeof value}`
if (typeof value !== 'function') message += ` (${value.toString()})`
throw new Error(message)
}
}
}
}

function configure (options) {
options = { ...options }
const fail = getStrictOption(options)
if (fail) {
if (options.bigint === undefined) {
options.bigint = false
}
if (!('circularValue' in options)) {
options.circularValue = Error
}
}
const circularValue = getCircularValueOption(options)
const bigint = getBooleanOption(options, 'bigint')
const deterministic = getBooleanOption(options, 'deterministic')
Expand Down Expand Up @@ -302,11 +330,18 @@ function configure (options) {
return `{${res}}`
}
case 'number':
return isFinite(value) ? String(value) : 'null'
return isFinite(value) ? String(value) : fail ? fail(value) : 'null'
case 'boolean':
return value === true ? 'true' : 'false'
case 'undefined':
return undefined
case 'bigint':
return bigint ? String(value) : undefined
if (bigint) {
return String(value)
}
// fallthrough
default:
return fail ? fail(value) : undefined
}
}

Expand Down Expand Up @@ -387,11 +422,18 @@ function configure (options) {
return `{${res}}`
}
case 'number':
return isFinite(value) ? String(value) : 'null'
return isFinite(value) ? String(value) : fail ? fail(value) : 'null'
case 'boolean':
return value === true ? 'true' : 'false'
case 'undefined':
return undefined
case 'bigint':
return bigint ? String(value) : undefined
if (bigint) {
return String(value)
}
// fallthrough
default:
return fail ? fail(value) : undefined
}
}

Expand Down Expand Up @@ -490,11 +532,18 @@ function configure (options) {
return `{${res}}`
}
case 'number':
return isFinite(value) ? String(value) : 'null'
return isFinite(value) ? String(value) : fail ? fail(value) : 'null'
case 'boolean':
return value === true ? 'true' : 'false'
case 'undefined':
return undefined
case 'bigint':
return bigint ? String(value) : undefined
if (bigint) {
return String(value)
}
// fallthrough
default:
return fail ? fail(value) : undefined
}
}

Expand Down Expand Up @@ -583,11 +632,18 @@ function configure (options) {
return `{${res}}`
}
case 'number':
return isFinite(value) ? String(value) : 'null'
return isFinite(value) ? String(value) : fail ? fail(value) : 'null'
case 'boolean':
return value === true ? 'true' : 'false'
case 'undefined':
return undefined
case 'bigint':
return bigint ? String(value) : undefined
if (bigint) {
return String(value)
}
// fallthrough
default:
return fail ? fail(value) : undefined
}
}

Expand Down
19 changes: 13 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# safe-stable-stringify

Safe, deterministic and fast serialization alternative to [JSON.stringify][]. Zero dependencies. ESM and CJS. 100% coverage.
Safe, deterministic and fast serialization alternative to [JSON.stringify][].
Zero dependencies. ESM and CJS. 100% coverage.

Gracefully handles circular structures and bigint instead of throwing.

Optional custom circular values and deterministic behavior.
Optional custom circular values, deterministic behavior or strict JSON
compatibility check.

## stringify(value[, replacer[, space]])

Expand Down Expand Up @@ -47,7 +49,7 @@ stringify(circular, ['a', 'b'], 2)
* `circularValue` {string|null|undefined|ErrorConstructor} Defines the value for
circular references. Set to `undefined`, circular properties are not
serialized (array entries are replaced with `null`). Set to `Error`, to throw
on circular references. **Default:** `[Circular]`.
on circular references. **Default:** `'[Circular]'`.
* `deterministic` {boolean} If `true`, guarantee a deterministic key order
instead of relying on the insertion order. **Default:** `true`.
* `maximumBreadth` {number} Maximum number of entries to serialize per object
Expand All @@ -58,6 +60,10 @@ stringify(circular, ['a', 'b'], 2)
* `maximumDepth` {number} Maximum number of object nesting levels (at least 1)
that will be serialized. Objects at the maximum level are serialized as
`'[Object]'` and arrays as `'[Array]'`. **Default:** `Infinity`
* `strict` {boolean} Instead of handling any JSON value gracefully, throw an
error in case it may not be represented as JSON (functions, NaN, ...).
Circular values and bigint values throw as well in case either option is not
explicitly defined. Sets and Maps are not detected! **Default:** `false`
* Returns: {function} A stringify function with the options applied.

```js
Expand Down Expand Up @@ -101,9 +107,10 @@ throwOnCircular(circular);

## Differences to JSON.stringify

1. _Circular values_ are replaced with the string `[Circular]` (the value may be changed).
1. _Object keys_ are sorted instead of using the insertion order (it is possible to deactivate this).
1. _BigInt_ values are stringified as regular number instead of throwing a TypeError.
1. _Circular values_ are replaced with the string `[Circular]` (configurable).
1. _Object keys_ are sorted instead of using the insertion order (configurable).
1. _BigInt_ values are stringified as regular number instead of throwing a
TypeError (configurable).
1. _Boxed primitives_ (e.g., `Number(5)`) are not unboxed and are handled as
regular object.

Expand Down
Loading

0 comments on commit f001e74

Please sign in to comment.