UNPKG

decycle

Version:

JSON decycle replaces circular references with JSON path references

111 lines (95 loc) 3.27 kB
type ReplacerFunction = (value: unknown) => unknown; interface DecycledObject { [key: string]: unknown; $ref?: string; } /** * Makes a deep copy of an object or array, assuring that there is at most * one instance of each object or array in the resulting structure. The * duplicate references (which might be forming cycles) are replaced with * an object of the form `{"$ref": PATH}` where the PATH is a JSONPath * string that locates the first occurance. * * @example * ```ts * var a = []; * a[0] = a; * JSON.stringify(decycle(a)); // '[{"$ref":"$"}]' * ``` * * If a replacer function is provided, then it will be called for each value. * A replacer function receives a value and returns a replacement value. * * JSONPath is used to locate the unique object. `$` indicates the top level of * the object or array. `[NUMBER]` or `[STRING]` indicates a child element or * property. * * @param value - The object or array to decycle. * @param replacer - Optional replacer function called for each value. * @returns - A deep copy of the object with circular references replaced by `$ref` objects. */ export function decycle(value: unknown, replacer?: ReplacerFunction) { const visitedObjects = new WeakMap<object, string>(); return deepCopy(value, '$', visitedObjects, replacer); } /** * Recursively deep copies a value, replacing circular references with * `{"$ref": PATH}` objects. * * @param value - The current value to copy. * @param path - The JSONPath to the current value. * @param visitedObjects - WeakMap tracking already-visited objects and their paths. * @param replacer - Optional replacer function called for each value. * @returns - The deep-copied value. */ function deepCopy( value: unknown, path: string, visitedObjects: WeakMap<object, string>, replacer?: ReplacerFunction, ): unknown { if (typeof replacer === 'function') { value = replacer(value); } if (!isPlainObjectOrArray(value)) { return value; } const existingPath = visitedObjects.get(value); if (existingPath !== undefined) { return { $ref: existingPath }; } visitedObjects.set(value, path); if (Array.isArray(value)) { const copy: unknown[] = []; for (const [index, element] of value.entries()) { const newPath = `${path}[${index.toString()}]`; copy[index] = deepCopy(element, newPath, visitedObjects, replacer); } return copy; } const record = value as Record<string, unknown>; const copy: DecycledObject = {}; for (const key of Object.keys(record)) { const newPath = `${path}[${JSON.stringify(key)}]`; copy[key] = deepCopy(record[key], newPath, visitedObjects, replacer); } return copy; } /** * Checks whether a value is a plain object or array (not a primitive or * built-in wrapper like `Boolean`, `Date`, `Number`, `RegExp`, or `String`). * * @param value - The value to check. * @returns - `true` if the value is a plain object or array. */ function isPlainObjectOrArray(value: unknown): value is object { return ( typeof value === 'object' && value !== null && !(value instanceof Boolean) && !(value instanceof Date) && !(value instanceof Number) && !(value instanceof RegExp) && !(value instanceof String) ); }