decycle
Version:
JSON decycle replaces circular references with JSON path references
111 lines (95 loc) • 3.27 kB
text/typescript
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)
);
}