UNPKG

topkat-utils

Version:

A comprehensive collection of TypeScript/JavaScript utility functions for common programming tasks. Includes validation, object manipulation, date handling, string formatting, and more. Zero dependencies, fully typed, and optimized for performance.

463 lines (409 loc) 18.8 kB
//---------------------------------------- // OBJECT UTILS //---------------------------------------- import { ObjectGeneric } from './types' import { err500IfNotSet } from './error-utils' import { recursiveGenericFunctionSync } from './loop-utils' import { isset } from './isset' import { isObject } from './is-object' import { DescriptiveError } from './error-utils' import { escapeRegexp } from './regexp-utils' /** Return an object: * * with only selected fields * * OR without masked fields */ export function simpleObjectMaskOrSelect<Obj extends ObjectGeneric>( object: Obj, maskedOrSelectedFields: (keyof Obj)[], mode: 'mask' | 'select' = 'mask', deleteKeysInsteadOfReturningAnewObject = false ): Obj { const allKeys = Object.keys(object) const keysToMask = allKeys.filter(keyName => { if (mode === 'mask') return maskedOrSelectedFields.includes(keyName) else return !maskedOrSelectedFields.includes(keyName) }) if (deleteKeysInsteadOfReturningAnewObject) { keysToMask.forEach(keyNameToDelete => delete object[keyNameToDelete]) return object } else { return allKeys.reduce((newObject, key) => { if (!keysToMask.includes(key)) newObject[key] = object[key] return newObject }, {}) as Obj } } /** * check if **object OR array** has property Safely (avoid cannot read property x of null and such) * @param {Object} obj object to test against * @param {string} addr `a.b.c.0.1` will test if myObject has props a that has prop b. Work wit arrays as well (like `arr.0`) */ export function has(obj: ObjectGeneric, addr: string) { if (!isset(obj) || typeof obj !== 'object') return const propsArr = addr.replace(/\.?\[(\d+)\]/g, '.$1').split('.') // replace a[3] => a.3; let objChain = obj return propsArr.every(prop => { objChain = objChain[prop] return isset(objChain) }) } /** Find address in an object "a.b.c" IN { a : { b : {c : 'blah' }}} RETURNS 'blah' * @param obj * @param addr accept syntax like "obj.subItem.[0].sub2" OR "obj.subItem.0.sub2" OR "obj.subItem[0].sub2" * @returns the last item of the chain OR undefined if not found */ export function findByAddress(obj: ObjectGeneric, addr: string | string[]): any | undefined { if (addr.length === 0) return obj // eslint-disable-next-line no-console if (!isset(obj) || typeof obj !== 'object') return console.warn('Main object in `findByAddress` function is undefined or has the wrong type') const propsArr = Array.isArray(addr) ? addr : addr.replace(/\.?\[(\d+)\]/g, '.$1').split('.') // replace .[4] AND [4] TO .4 const objRef = propsArr.reduce((objChain, prop) => { if (!isset(objChain) || typeof objChain !== 'object' || !isset(objChain[prop])) return else return objChain[prop] }, obj) return objRef } type FindByAddressReturnFull = Array<[addr: string, value: any, lastElmKey: string, parent: any[] | Record<string, any>]> /** Will return all objects matching that path. Eg: user.*.myVar */ export function findByAddressAll<ReturnAddresses extends boolean = false>( obj: Record<string, any>, addr: string, returnAddresses: ReturnAddresses = false as ReturnAddresses ): ReturnAddresses extends true ? FindByAddressReturnFull : Array<any> { err500IfNotSet({ obj, addr }) if (addr === '') return (returnAddresses ? [addr, obj, undefined, undefined] : obj) as any const addrRegexp = new RegExp('^' + escapeRegexp( addr.replace(/\.?\[(\d+)\]/g, '.$1'), // replace .[4] AND [4] TO .4 { parseWildcard: true, wildcardNotMatchingChars: '.[' }) + '$' ) const matchingItems: any[] = [] recursiveGenericFunctionSync(obj, (item, address, lastElmKey, parent) => { if (addrRegexp.test(address)) matchingItems.push(returnAddresses ? [address, item, lastElmKey, parent] : item) }) return matchingItems } /** Enforce writing subItems. Eg: user.name.blah will ensure all are set until the writing of the last item * NOTE: doesn't work when parent is array */ export function objForceWrite<MainObj extends Record<string, any>>(obj: MainObj, addr: string, item, options: { doNotWriteFinalValue?: boolean } = {}): MainObj { const { doNotWriteFinalValue = false } = options const writeFinalValue = !doNotWriteFinalValue const chunks = addr.replace(/\.?\[(\d+)\]/g, '.[$1').split('.') let lastItem: any = obj chunks.forEach((chunkRaw, i) => { const chunk = chunkRaw.replace(/^\[/, '') if (i === chunks.length - 1) { if (writeFinalValue) lastItem[chunk] = item } else if (!isset(lastItem[chunk])) { const nextChunk = chunks[i + 1] if (isset(nextChunk) && nextChunk.startsWith('[')) lastItem[chunk] = [] else lastItem[chunk] = {} } else if (typeof lastItem[chunk] !== 'object') { throw new DescriptiveError(`itemNotTypeObjectOrArrayInAddrChainForObjForceWrite`, { code: 500, origin: 'Validator', chunks: chunks.map(c => c.replace(/\[(\d+)/, '[$1]')), actualValueOfItem: lastItem[chunk], actualChunk: chunk, chunkIndex: i }) } lastItem = lastItem[chunk] }) return obj } export function forcePathInObject<MainObj extends Record<string, any>>(obj: MainObj, addr: string): MainObj { return objForceWrite(obj, addr, undefined, { doNotWriteFinalValue: true }) } export const objForceWritePath = forcePathInObject /** Enforce writing subItems, only if obj.addr is empty. * Eg: user.name.blah will ensure all are set until the writing of the last item * if user.name.blah has a value it will not change it. * NOTE: doesn't work when parent is array */ export function objForceWriteIfNotSet<MainObj extends Record<string, any>>(obj: MainObj, addr: string, item): MainObj { if (!isset(findByAddress(obj, addr))) return objForceWrite(obj, addr, item) else return obj } /** Merge mixins into class. Use it in the constructor like: mergeMixins(this, {myMixin: true}) */ export function mergeMixins(that, ...mixins) { mixins.forEach(mixin => { for (const method in mixin) { that[method] = mixin[method] } }) } export function cloneObject<MainObj extends Record<string, any>>(o: MainObj): MainObj { return JSON.parse(JSON.stringify(o)) } /** Deep clone. WILL REMOVE circular references */ export function deepClone<MainObj extends Record<string, any>>(obj: MainObj, cache = []): MainObj { let copy: any | any[] // usefull to not modify 1st level objet by lower levels // this is required for the same object to be referenced not in a redundant way const newCache: any[] = [...cache] if (obj instanceof Date) return new Date(obj) as any // Handle Array if (Array.isArray(obj)) { if (newCache.includes(obj)) return [] as any newCache.push(obj) copy = [] for (let i = 0, len = obj.length; i < len; i++) { copy[i] = deepClone(obj[i], newCache as any) } return copy } if (typeof obj === 'object' && obj !== null && Object.getPrototypeOf(obj) === Object.prototype) { if (newCache.includes(obj)) return {} as any newCache.push(obj) copy = {} for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { copy[key] = deepClone(obj[key], newCache as any) } } return copy } return obj // number, string... } /** * @param {Object} obj the object on which we want to filter the keys * @param {function} filterFunc function that returns true if the key match the wanted criteria */ export function filterKeys<MainObj extends Record<string, any>>(obj: MainObj, filter): MainObj { const clone = cloneObject(obj) recursiveGenericFunctionSync(obj, (_, addr, lastElementKey) => { if (!filter(lastElementKey)) deleteByAddress(clone, addr.split('.')) }) return clone } /** * @param {Object} obj the object on which we want to delete a property * @param {Array} addrArr addressArray on which to delete the property */ export function deleteByAddress(obj: object, addr: string | string[]) { let current = obj const addrArr = Array.isArray(addr) ? addr : addr.split('.') for (let i = 0; i < addrArr.length; i++) { const currentAddr = addrArr[i].replace(/(\[|\])/g, '') if (i === addrArr.length - 1) delete current[currentAddr] else current = current[currentAddr] } } /** Remove all key/values pair if value is undefined */ export function objFilterUndefined<MainObj extends Record<string, any>>(o: MainObj): MainObj { Object.keys(o).forEach(k => !isset(o[k]) && delete o[k]) return o } /** Lock all 1st level props of an object to read only */ export function readOnly<MainObj extends Record<string, any>>(o: MainObj): { readonly [AA in keyof MainObj]: MainObj[AA] } { const throwErr = () => { throw new DescriptiveError('Cannot modify object that is read only', { code: 500 }) } return new Proxy(o, { set: throwErr, defineProperty: throwErr, deleteProperty: throwErr, }) } /** Fields of the object can be created BUT NOT reassignated */ export function reassignForbidden(o) { return new Proxy(o, { defineProperty: function (that, key, value) { if (key in that) throw new DescriptiveError(`Cannot reassign the property ${key.toString()} of this object`, { code: 500 }) else { that[key] = value return true } }, deleteProperty: function (_, key) { throw new DescriptiveError(`Cannot delete the property ${key.toString()} of this object`, { code: 500 }) } }) } /** All fileds and subFields of the object will become readOnly */ export function readOnlyRecursive(object) { recursiveGenericFunctionSync(object, (item, _, lastElementKey, parent) => { if (typeof item === 'object') parent[lastElementKey] = readOnly(item) }) return object } /** @deprecated use readOnlyRecursive instead */ export const readOnlyForAll = readOnlyRecursive export function objFilterUndefinedRecursive(obj) { if (obj) { const flattenedObj = flattenObject(obj) Object.keys(flattenedObj).forEach(key => { if (!isset(flattenedObj[key])) { delete flattenedObj[key] } }) return unflattenObject(flattenedObj) } else return obj } export function sortObjKeyAccordingToValue(unorderedObj, ascending = true) { const orderedObj = {} const sortingConst = ascending ? 1 : -1 Object.keys(unorderedObj) .sort((keyA, keyB) => unorderedObj[keyA] < unorderedObj[keyB] ? sortingConst : -sortingConst) .forEach(key => { orderedObj[key] = unorderedObj[key] }) return orderedObj } /** * Make default value if object key do not exist * @param {object} obj * @param {string} addr * @param {any} defaultValue * @param {function} callback (obj[addr]) => processValue. Eg: myObjAddr => myObjAddr.push('bikou') * @return obj[addr] eventually processed by the callback */ export function ensureObjectProp<MainObj extends Record<string, any>, Addr extends string>( obj: MainObj, addr: Addr, defaultValue, callback: (o: any) => any ): MainObj[Addr] { err500IfNotSet({ obj, addr, defaultValue, callback }) if (!isset(obj[addr])) obj[addr] = defaultValue if (callback) callback(obj[addr]) return obj[addr] } /** object and array merge * @warn /!\ Array will be merged and duplicate values will be deleted /!\ * @return {Object} new object result from merge * NOTE: objects in params will NOT be modified*/ export function mergeDeep< O1 extends Record<string, any>, O2 extends Record<string, any> = Record<string, any>, O3 extends Record<string, any> = Record<string, any>, O4 extends Record<string, any> = Record<string, any>, O5 extends Record<string, any> = Record<string, any>, O6 extends Record<string, any> = Record<string, any>, >(...objects: [O1, O2?, O3?, O4?, O5?, O6?]): O1 & O2 & O3 & O4 & O5 & O6 { return mergeDeepConfigurable( (previousVal, currentVal) => [...previousVal, ...currentVal].filter((elm, i, arr) => arr.indexOf(elm) === i), (previousVal, currentVal) => mergeDeep(previousVal, currentVal), undefined, ...objects ) } /** object and array merge * @warn /!\ Array will be replaced by the latest object /!\ * @return {Object} new object result from merge * NOTE: objects in params will NOT be modified */ export function mergeDeepOverrideArrays< O1 extends Record<string, any>, O2 extends Record<string, any> = Record<string, any>, O3 extends Record<string, any> = Record<string, any>, O4 extends Record<string, any> = Record<string, any>, O5 extends Record<string, any> = Record<string, any>, O6 extends Record<string, any> = Record<string, any>, >(...objects: [O1, O2?, O3?, O4?, O5?, O6?]): O1 & O2 & O3 & O4 & O5 & O6 { return mergeDeepConfigurable( undefined, (previousVal, currentVal) => mergeDeepOverrideArrays(previousVal, currentVal), undefined, ...objects ) } /** object and array merge * @param {Function} replacerForArrays item[key] = (prevValue, currentVal) => () When 2 values are arrays, * @param {Function} replacerForObjects item[key] = (prevValue, currentVal) => () When 2 values are objects, * @param {Function} replacerDefault item[key] = (prevValue, currentVal) => () For all other values * @param {...Object} objects * @return {Object} new object result from merge * NOTE: objects in params will NOT be modified */ export function mergeDeepConfigurable< O1 extends Record<string, any>, O2 extends Record<string, any> = Record<string, any>, O3 extends Record<string, any> = Record<string, any>, O4 extends Record<string, any> = Record<string, any>, O5 extends Record<string, any> = Record<string, any>, O6 extends Record<string, any> = Record<string, any>, >( replacerForArrays = (_, curr) => curr, replacerForObjects, replacerDefault = (_, curr) => curr, ...objects: [O1, O2?, O3?, O4?, O5?, O6?] ): O1 & O2 & O3 & O4 & O5 & O6 { return objects.reduce((actuallyMerged, obj) => { if (obj && typeof obj === 'object') Object.keys(obj).forEach(key => { const previousVal = actuallyMerged[key] const currentVal = obj[key] if (Array.isArray(previousVal) && Array.isArray(currentVal)) { actuallyMerged[key] = replacerForArrays(previousVal, currentVal) } else if (isObject(previousVal) && isObject(currentVal)) { actuallyMerged[key] = replacerForObjects(previousVal, currentVal) } else { actuallyMerged[key] = replacerDefault(previousVal, currentVal) } }) return actuallyMerged }, {}) as any } /** { a: {b:2}} => {'a.b':2} useful for translations * NOTE: will remove circular references */ export function flattenObject(data, config: { withoutArraySyntax?: boolean, withArraySyntaxMinified?: boolean } = {}): Record<string, any> { const { withoutArraySyntax = false, withArraySyntaxMinified = false } = config const result = {} const seenObjects: any[] = [] // avoidCircular reference to infinite loop const recurse = (cur, prop) => { if (Array.isArray(cur)) { const l = cur.length let i = 0 if (withoutArraySyntax) recurse(cur[0], prop) else { for (; i < l; i++) recurse(cur[i], prop + (withArraySyntaxMinified ? `.${i}` : `[${i}]`)) if (l == 0) result[prop] = [] } } else if (isObject(cur)) { // is object try { if (seenObjects.includes(cur)) cur = deepClone(cur) // avoid circular ref but allow duplicate objects else seenObjects.push(cur) const isEmpty = Object.keys(cur).length === 0 for (const p in cur) recurse(cur[p], (prop ? prop + '.' : '') + p.replace(/\./g, '%')) // allow prop to contain special chars like points); if (isEmpty && prop) result[prop] = {} // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { // eslint-disable-next-line no-console console.warn('Circular reference in flattenObject, impossible to parse') } } else result[prop] = cur } recurse(data, '') return result } /** {'a.b':2} => { a: {b:2}} */ export function unflattenObject(data: Record<string, any>): Record<string, any> { const newO = {} for (const [addr, value] of Object.entries(data)) objForceWrite(newO, addr, value) return newO } /** Mean to fix typing because type for Object.entries is not accurate. Ref: https://stackoverflow.com/questions/66565322/get-type-keys-in-typescript * /!\ THIS WILL REMOVE SYMBOL AND NUMBER FROM KEY TYPES as this is 99% of the time unwanted for generic objects */ export function objEntries<Obj extends Record<string, any>>(obj: Obj): ObjEntries<Obj> { return Object.entries(obj) as any } /** Will remove Symbol and Number from keys types */ type ObjEntries<T, K extends keyof T = keyof T> = (K extends string ? [K, T[K]] : never)[] /** Will remove Symbol and Number from keys types */ type StringKeys<T> = keyof T extends infer K ? K extends string ? K : never : never /** Mean to fix typing because type for Object.keys is not accurate */ export function objKeys<Obj extends Record<string, any>>(obj: Obj): StringKeys<Obj>[] { return Object.keys(obj) as any } /** Will merge all arrays of an object into a single array */ export function mergeObjectArrays<T extends Record<string, any[]>>(obj: T) { return Object.values(obj).flat() as (T[keyof T][number])[] } export const keys = objKeys export const entries = objEntries /** A Helper to create JavascriptProxies, will add __isProxy and toJSON helper to prevent error when logging the proxy and to be able to check if the object is proxyfied */ export function createProxy<T extends Record<string, any>>(obj: T, optn: { get: Required<ProxyHandler<T>>['get'], jsonRepresentation?: (obj: T) => string }) { return new Proxy(obj, { get(target, prop, receiver) { if (prop === '__isProxy') return true else if (prop === 'toJSON') { if ('toJSON' in target) return target.toJSON else return () => (optn?.jsonRepresentation?.(target) || '[Proxy]') } return optn.get(target, prop, receiver) } }) }