const isObject = value => { const type = typeof value; return value !== null && (type === 'object' || type === 'function'); }; const isEmptyObject = value => isObject(value) && Object.keys(value).length === 0; const disallowedKeys = new Set([ '__proto__', 'prototype', 'constructor', ]); const digits = new Set('0123456789'); function getPathSegments(path) { const parts = []; let currentSegment = ''; let currentPart = 'start'; let isIgnoring = false; for (const character of path) { switch (character) { case '\\': { if (currentPart === 'index') { throw new Error('Invalid character in an index'); } if (currentPart === 'indexEnd') { throw new Error('Invalid character after an index'); } if (isIgnoring) { currentSegment += character; } currentPart = 'property'; isIgnoring = !isIgnoring; break; } case '.': { if (currentPart === 'index') { throw new Error('Invalid character in an index'); } if (currentPart === 'indexEnd') { currentPart = 'property'; break; } if (isIgnoring) { isIgnoring = false; currentSegment += character; break; } if (disallowedKeys.has(currentSegment)) { return []; } parts.push(currentSegment); currentSegment = ''; currentPart = 'property'; break; } case '[': { if (currentPart === 'index') { throw new Error('Invalid character in an index'); } if (currentPart === 'indexEnd') { currentPart = 'index'; break; } if (isIgnoring) { isIgnoring = false; currentSegment += character; break; } if (currentPart === 'property') { if (disallowedKeys.has(currentSegment)) { return []; } parts.push(currentSegment); currentSegment = ''; } currentPart = 'index'; break; } case ']': { if (currentPart === 'index') { parts.push(Number.parseInt(currentSegment, 10)); currentSegment = ''; currentPart = 'indexEnd'; break; } if (currentPart === 'indexEnd') { throw new Error('Invalid character after an index'); } // Falls through } default: { if (currentPart === 'index' && !digits.has(character)) { throw new Error('Invalid character in an index'); } if (currentPart === 'indexEnd') { throw new Error('Invalid character after an index'); } if (currentPart === 'start') { currentPart = 'property'; } if (isIgnoring) { isIgnoring = false; currentSegment += '\\'; } currentSegment += character; } } } if (isIgnoring) { currentSegment += '\\'; } switch (currentPart) { case 'property': { if (disallowedKeys.has(currentSegment)) { return []; } parts.push(currentSegment); break; } case 'index': { throw new Error('Index was not closed'); } case 'start': { parts.push(''); break; } // No default } return parts; } function isStringIndex(object, key) { if (typeof key !== 'number' && Array.isArray(object)) { const index = Number.parseInt(key, 10); return Number.isInteger(index) && object[index] === object[key]; } return false; } function assertNotStringIndex(object, key) { if (isStringIndex(object, key)) { throw new Error('Cannot use string index'); } } export function getProperty(object, path, value) { if (!isObject(object) || typeof path !== 'string') { return value === undefined ? object : value; } const pathArray = getPathSegments(path); if (pathArray.length === 0) { return value; } for (let index = 0; index < pathArray.length; index++) { const key = pathArray[index]; if (isStringIndex(object, key)) { object = index === pathArray.length - 1 ? undefined : null; } else { object = object[key]; } if (object === undefined || object === null) { // `object` is either `undefined` or `null` so we want to stop the loop, and // if this is not the last bit of the path, and // if it didn't return `undefined` // it would return `null` if `object` is `null` // but we want `get({foo: null}, 'foo.bar')` to equal `undefined`, or the supplied value, not `null` if (index !== pathArray.length - 1) { return value; } break; } } return object === undefined ? value : object; } export function setProperty(object, path, value) { if (!isObject(object) || typeof path !== 'string') { return object; } const root = object; const pathArray = getPathSegments(path); for (let index = 0; index < pathArray.length; index++) { const key = pathArray[index]; assertNotStringIndex(object, key); if (index === pathArray.length - 1) { object[key] = value; } else if (!isObject(object[key])) { object[key] = typeof pathArray[index + 1] === 'number' ? [] : {}; } object = object[key]; } return root; } export function deleteProperty(object, path) { if (!isObject(object) || typeof path !== 'string') { return false; } const pathArray = getPathSegments(path); for (let index = 0; index < pathArray.length; index++) { const key = pathArray[index]; assertNotStringIndex(object, key); if (index === pathArray.length - 1) { delete object[key]; return true; } object = object[key]; if (!isObject(object)) { return false; } } } export function hasProperty(object, path) { if (!isObject(object) || typeof path !== 'string') { return false; } const pathArray = getPathSegments(path); if (pathArray.length === 0) { return false; } for (const key of pathArray) { if (!isObject(object) || !(key in object) || isStringIndex(object, key)) { return false; } object = object[key]; } return true; } // TODO: Backslashes with no effect should not be escaped export function escapePath(path) { if (typeof path !== 'string') { throw new TypeError('Expected a string'); } return path.replaceAll(/[\\.[]/g, '\\$&'); } // The keys returned by Object.entries() for arrays are strings function entries(value) { const result = Object.entries(value); if (Array.isArray(value)) { return result.map(([key, value]) => [Number(key), value]); } return result; } function stringifyPath(pathSegments) { let result = ''; for (let [index, segment] of entries(pathSegments)) { if (typeof segment === 'number') { result += `[${segment}]`; } else { segment = escapePath(segment); result += index === 0 ? segment : `.${segment}`; } } return result; } function * deepKeysIterator(object, currentPath = []) { if (!isObject(object) || isEmptyObject(object)) { if (currentPath.length > 0) { yield stringifyPath(currentPath); } return; } for (const [key, value] of entries(object)) { yield * deepKeysIterator(value, [...currentPath, key]); } } export function deepKeys(object) { return [...deepKeysIterator(object)]; }