function isObject(obj: unknown) { return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } function isEmptyObject(obj: unknown) { return typeof obj === 'object' && obj !== null && !Array.isArray(obj) && !Object.keys(obj).length; } function isEmptyArray(arr: unknown) { return Array.isArray(arr) && arr.length === 0; } type PreservationContext = 'arrayItem' | 'objectProperty' | 'root'; type PreservationRule = boolean | Partial>; interface RemovalOptions { preserveEmptyArray?: PreservationRule; preserveEmptyObject?: PreservationRule; preserveNullishArrays?: boolean; removeAllFalsy?: boolean; } function shouldPreserve(rule: PreservationRule | undefined, context: PreservationContext) { if (typeof rule === 'boolean') { return rule; } return rule?.[context] ?? false; } // Remove objects that has undefined value or recursively contain undefined values function removeUndefined(obj: any): any { if (obj === undefined) { return undefined; } // Preserve null if (obj === null) { return null; } // Remove undefined in arrays if (Array.isArray(obj)) { return obj.map(removeUndefined).filter(item => item !== undefined); } if (typeof obj === 'object') { const cleaned: Record = {}; Object.entries(obj).forEach(([key, value]) => { const cleanedValue = removeUndefined(value); if (cleanedValue !== undefined) { cleaned[key] = cleanedValue; } }); return cleaned; } return obj; } // Modified from here: https://stackoverflow.com/a/43781499 function stripEmptyObjects(obj: any, options: RemovalOptions = {}) { const cleanObj = obj; if (obj === null && options.removeAllFalsy) { return; } if (!isObject(obj) && !Array.isArray(cleanObj)) { return cleanObj; } if (!Array.isArray(cleanObj)) { Object.keys(cleanObj).forEach(key => { let value = cleanObj[key]; if (typeof value !== 'object') { return; } if (value === null) { if (options.removeAllFalsy) { delete cleanObj[key]; } return; } value = stripEmptyObjects(value, options); if (isEmptyObject(value) && !shouldPreserve(options.preserveEmptyObject, 'objectProperty')) { delete cleanObj[key]; } else if (isEmptyArray(value) && !shouldPreserve(options.preserveEmptyArray, 'objectProperty')) { delete cleanObj[key]; } else { cleanObj[key] = value; } }); return cleanObj; } cleanObj.forEach((o, idx) => { let value = o; if (typeof value === 'object' && value !== null) { value = stripEmptyObjects(value, options); if (isEmptyObject(value) && !shouldPreserve(options.preserveEmptyObject, 'arrayItem')) { delete cleanObj[idx]; } else if (isEmptyArray(value) && !shouldPreserve(options.preserveEmptyArray, 'arrayItem')) { delete cleanObj[idx]; } else { cleanObj[idx] = value; } } else if (value === null && (options.removeAllFalsy || !options.preserveNullishArrays)) { // Null entries within an array should be removed by default, unless explicitly preserved delete cleanObj[idx]; } }); // Since deleting a key from an array will retain an undefined value in that array, we need to // filter them out. return cleanObj.filter(el => el !== undefined); } export default function removeUndefinedObjects(obj?: T, options?: RemovalOptions): T | undefined { if (obj === undefined) { return undefined; } // If array nulls are preserved, use the custom removeUndefined function so that // undefined values in arrays aren't converted to nulls, which stringify does // If we're not preserving array nulls (default behavior), it doesn't matter that the undefined array values are converted to nulls // oxlint-disable-next-line readme/json-parse-try-catch -- If this fails we should fail. let withoutUndefined = options?.preserveNullishArrays ? removeUndefined(obj) : JSON.parse(JSON.stringify(obj)); // Then we recursively remove all empty objects and nullish arrays withoutUndefined = stripEmptyObjects(withoutUndefined, options); // If the only thing that's leftover is an empty object or empty array then return nothing. if ( (isEmptyObject(withoutUndefined) && !shouldPreserve(options?.preserveEmptyObject, 'root')) || (isEmptyArray(withoutUndefined) && !shouldPreserve(options?.preserveEmptyArray, 'root')) ) { return; } return withoutUndefined; }