Search code examples
javascripttypescriptmergetype-inferencereturn-type

Trying to deep merge on type level


I have a JavaScript mergeDeep function that recursively merges objects and arrays. It works as expected in terms of functionality, but I want to improve it by ensuring that the return type of the merged object accurately represents the combination of the target and source types. The current implementation does not provide this type information.

Here's the function for reference:

function mergeDeep(target, ...sources) {
  if (!sources.length) return target;

  const source = sources.shift();

  for (const key in source) {
    if (!Object.prototype.hasOwnProperty.call(source, key)) continue;

    const targetValue = target[key];
    const sourceValue = source[key];

    if (isArray(targetValue) && isArray(sourceValue)) {
      target[key] = targetValue.concat(sourceValue);
    } else if (isObject(targetValue) && isObject(sourceValue)) {
      target[key] = mergeDeep(Object.assign({}, targetValue), sourceValue);
    } else {
      target[key] = source[key];
    }
  }

  return mergeDeep(target, ...sources);
}

I've tried various modifications to the function, but I'm unsure how to achieve the desired return type. My goal is to have the return type inferred correctly for different merge scenarios, such as:

mergeDeep({ numbers: [1, 2, 3]}, [4, 5, 6]) // Return type should be { 0: number, 1: number, 2: number, numbers: number[] }
mergeDeep({ strings: ['a', 'b', 'c']}, { strings: ['d', 'e', 'f']}) // Return type should be { strings: string[] }
mergeDeep({ foo: ['bar']}, { foo: [55]}) // Return type should be { foo: (string | number)[] }
mergeDeep({ a: 1 }, { b: 2 }) // Return type should be { a: number, b: number }
mergeDeep({ a: 1 }, { a: 2 }) // Return type should be { a: number }
mergeDeep({ a: { b: 1 } }, { a: { c: 2 } }) // Return type should be { a: { b: number, c: number } }

I tried to do something from the combination of the comments, and it works, but there is also one problem: Playground


Solution

  • So we need to give mergeDeep() an appropriate call signature. Something like

    function mergeDeep<
      T extends object,
      U extends Array<object>
    >(target: T, ...sources: U): MergeDeep<T, U>;
    

    where MergeDeep<T, U> takes a type T and a tuple of types U and merges them. That can be written as

    type MergeDeep<T, U extends readonly any[]> =
      U extends readonly [infer F, ...infer R] ? 
        MergeDeep<MergeDeepTwo<T, F>, R> : T
    

    which is a tail recursive conditional type that expresses MergeDeep<T, U> in terms of a basic MergeDeepTwo<T, U> operation that takes just a single type for U instead of a tuple.

    Then MergeDeepTwo<T, U> could be defined like:

    type MergeDeepTwo<T, U> =
      T extends readonly any[] ? (
        U extends readonly any[] ? [...T, ...U] : U
      ) :
      T extends object ? (
        U extends readonly any[] ? U :
        U extends object ? MergeDeepTwoObj<T, U> :
        U
      ) :
      U;
    

    I tried to follow the logic of your mergeDeep() implementation. If both inputs are array types, then we concatenate them. That is represented as the variadic tuple type [...T, ...U]. Otherwise, if both inputs are non-array objects, we return MergeDeepTwoObj<T, U> for a suitable definition of that. And otherwise, we just return the second type U.

    So then MergeDeepTwoObj<T, U>, specifically operating on non-array object types T and U, can be written as:

    type MergeDeepTwoObj<T extends object, U extends object> =
      { [K in keyof (T & U)]:
        K extends keyof T ? (
          K extends keyof U ? MergeDeepTwo<T[K], U[K]> : T[K]
        ) : K extends keyof U ? U[K] : never
      } extends infer V ? { [K in keyof V]: V[K] } : never;
    

    This is a mapped type that operates on the keys of T & U which is essentially all the keys of T and all the keys of U put together. For any key K which is a key of just one of T and U, we just return the analogous property. Otherwise we take each property, T[K] and U[K], and recursively compute MergeDeepTwo<T[K], U[K]>.

    The bit at then end extends infer V ? { [K in keyof V]: V[K] } : never is essentially for cosmetic purposes, so that the output type is expanded into its properties instead of being shown as MergeDeepTwoObj<⋯, ⋯>. See How can I see the full expanded contract of a Typescript type? for more.


    Let's test it out:

    const test1 = mergeDeep({ strings: ['a', 'b', 'c'] }, { strings: ['d', 'e', 'f'] });
    //    ^?const test1: { strings: string[]; }
    const test2 = mergeDeep({ foo: ['bar'] }, { foo: [55] });
    //    ^? const test2: { foo: (string | number)[]; }
    const test3 = mergeDeep({ a: 1 }, { b: 2 });
    //    ^? const test3: { a: number; b: number; }
    const test4 = mergeDeep({ a: 1 }, { a: 2 });
    //    ^? const test4: { a: number; }
    const test5 = mergeDeep({ a: { b: 1 } }, { a: { c: '2' } });
    //    ^? const test5: { a: { b: number; c: string; }; }
    const test6 = mergeDeep({ a: 1 }, { a: ' 2' });
    //    ^? const test6: { a: string; }
    const test8 = mergeDeep({}, { a: 1 }, { a: '1' });
    //    ^? const test8: { a: string; }
    const test9 = mergeDeep({}, { a: 1 }, { b: 2 });
    //    ^? const test9: { a: number; b: number; }
    

    Looks good.


    That answers the question as asked with the use cases listed there. But note well that such deeply recursive generic types tend to have lots of strange edge cases, and sometimes clearing up those edge cases involves a significant amount of refactoring. So you might need to tweak or even completely replace the implementation in the face of such edge cases. And therefore it's important to thoroughly test against all use cases you care about before proceeding. Usually the goal should be to make a type that's good enough for most of the likely uses, knowing that there will probably always be uses that break it.

    Playground link to code