/** * Deep merge utility with "underwrite" pattern. * * Recursively merges source into target, where **target values take precedence**. * This is the opposite of typical deep merge where source wins. * * This "underwrite" pattern is used for config defaults: * - User config (target) always wins * - Defaults (source) only fill in missing values * * @param target - Target object (takes precedence) * @param source - Source object (provides defaults) * @returns New merged object (does not mutate inputs) * * @example * ```typescript * const userConfig = { api: { timeout: 5000 } }; * const defaults = { api: { timeout: 3000, retries: 3 }, debug: false }; * * const result = deepMerge(target, source); * // Result: { * // api: { timeout: 5000, retries: 3 }, // timeout from user, retries from defaults * // debug: false // debug from defaults * // } * ``` */ export function deepMerge( target: Record, source: Record ): Record { // Create a new object to avoid mutation const result: Record = { ...target }; for (const key in source) { if (!Object.hasOwn(source, key)) { continue; } const sourceValue = source[key]; const targetValue = result[key]; // If target has no value for this key, use source value if (targetValue === undefined) { result[key] = sourceValue; continue; } // If both are plain objects, recursively merge if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { result[key] = deepMerge(targetValue, sourceValue); } // Otherwise, target value wins (underwrite pattern) // result[key] is already set from { ...target } } return result; } /** * Check if value is a plain object (not array, null, Date, etc.) * * @param value - Value to check * @returns True if value is a plain object * @private */ function isPlainObject(value: any): value is Record { if (value === null || typeof value !== 'object') { return false; } // Check if it's a plain object (not Array, Date, RegExp, etc.) return Object.prototype.toString.call(value) === '[object Object]'; }