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
text/typescript
//----------------------------------------
// 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)
}
})
}