DEV Community

loading...

Typescript — how to Deep merge

svehla profile image Jakub Švehla Updated on ・6 min read

Step by step tutorial on how to create Typescript deep merge generic type which works with inconsistent key values structures.

TLDR:

Source code for DeepMergeTwoTypes generic is at bottom of the article.
You can copy-paste it into your IDE and play with it.

VS-CODE preview

Alt Text

Alt Text

Alt Text

Prerequisite

If you want to deep dive into advanced typescript types I recommend this typescript series full of useful examples.

Disclaimer

Usage of code from this article in the production at your own risk (BTW we use it 😏)

Typescript & operator behavior problem

First of all, we’ll look at the problem with the Typescript type merging. Let’s define two types A and B and a new type C which is the result of the merge A & B.

type A = { key1: string, key2: string }
type B = { key2: string, key3: string }
type C = A & B
const a = (c: C) => c.
Enter fullscreen mode Exit fullscreen mode

Alt Text

Everything looks good until you start to merge inconsistent data types.

type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
type C = A & B
Enter fullscreen mode Exit fullscreen mode

As you can see type A define key2 as a string but type B define key2 as a null value.

Alt Text

Typescript resolves this inconsistent type merging as type never and type C stops to work at all. Our expected output should be something like this

type ExpectedType = {
  key1: string | null,
  key2: string,
  key3: string
}
Enter fullscreen mode Exit fullscreen mode

Step-by-step Solution

Let’s created a proper generic that recursively deep merge Typescript types.

First of all, we define 2 helper generic types.

GetObjDifferentKeys<>

type GetObjDifferentKeys<T, U> = Omit<T, keyof U> & Omit<U, keyof T>
Enter fullscreen mode Exit fullscreen mode

this type takes 2 Objects and returns a new object contains only unique keys in A and B.

type A = { key1: string, key2: string }
type B = { key2: null, key3: string }

type C = GetObjDifferentKeys<A, B>['']
Enter fullscreen mode Exit fullscreen mode

Alt Text

GetObjSameKeys<>

For the opposite of the previous generic, we will define a new one that picks all keys which are the same in both objects.

type GetObjSameKeys<T, U> = Omit<T | U, keyof GetObjDifferentKeys<T, U>>
Enter fullscreen mode Exit fullscreen mode

The returned type is an object.

type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
type C = GetObjSameKeys<A, B>
Enter fullscreen mode Exit fullscreen mode

Alt Text

All helpers functions are Done so we can start to implement the main DeepMergeTwoTypes generic.

DeepMergeTwoTypes<>

type DeepMergeTwoTypes<T, U> =
  // non shared keys are optional
  Partial<GetObjDifferentKeys<T, U>>
  // shared keys are required
  & { [K in keyof GetObjSameKeys<T, U>]: T[K] | U[K] }
Enter fullscreen mode Exit fullscreen mode

This generic finds all nonshared keys between object T and U and makes them optional thanks to Partial<> generic provided by Typescript. This type with Optional keys is merged via & an operator with the object that contains all T and U shared keys which values are of type T[K] | U[K].

As you can see in the example below. New generic found non-shared keys and make them optional ? the rest of keys is strictly required.

type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
const fn = (c: DeepMergeTwoTypes<A, B>) => c.
Enter fullscreen mode Exit fullscreen mode

Alt Text

But our current DeepMergeTwoTypes generic does not work recursively to the nested structures types. So let’s extract Object merging functionality into a new generic called MergeTwoObjects and let DeepMergeTwoTypes call recursively until it merges all nested structures.

// this generic call recursively DeepMergeTwoTypes<>
type MergeTwoObjects<T, U> =
  // non shared keys are optional
  Partial<GetObjDifferentKeys<T, U>>
  // shared keys are required
  & {[K in keyof GetObjSameKeys<T, U>]: DeepMergeTwoTypes<T[K], U[K]>}

export type DeepMergeTwoTypes<T, U> =
  // check if generic types are arrays and unwrap it and do the recursion
  [T, U] extends [{ [key: string]: unknown}, { [key: string]: unknown } ]
    ? MergeTwoObjects<T, U>
    : T | U
Enter fullscreen mode Exit fullscreen mode

PRO TIP: You can see that in the DeepMergeTwoTypes an if-else condition we merged type T and U into tuple [T, U] for verifying that both types passed successfully the condition (similarly as the && operator in the javascript conditions)

This generic checks that both parameters are of type { [key: string]: unknown } (aka Object). If it’s true it merges them via MergeTwoObject<>. This process is recursively repeated for all nested objects.

And voilá 🎉 now the generic is recursively applied on all nested objects
example:

type A = { key: { a: null, c: string} }
type B = { key: { a: string, b: string} }
const fn = (c: MergeTwoObjects<A, B>) => c.key.
Enter fullscreen mode Exit fullscreen mode

Alt Text

Is that all?

Unfortunately not… Our new generic does not support Arrays.

before we will continue we have to know keyword infer

infer look for data structure and extract data type which is wrapped inside of them (in our case it extract data type of array) You can read more about infer functionality there:
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types

Example usage of infer keyword on example how to get Item type out of Item[]:

export type ArrayElement<A> = A extends (infer T)[] ? T : never

// Item === (number | string)
type Item = ArrayElement<(number | string)[]>
Enter fullscreen mode Exit fullscreen mode

Now we have to add proper support for Arrays just by simple 2 lines where we infer values of the elements of the array and recursively call DeepMergeTwoTypes for arrays items.

export type DeepMergeTwoTypes<T, U> =
  // ----- 2 added lines ------
  // this line ⏬
  [T, U] extends [(infer TItem)[], (infer UItem)[]]
    // ... and this line ⏬
    ? DeepMergeTwoTypes<TItem, UItem>[]
    : ... rest of previous generic ...
Enter fullscreen mode Exit fullscreen mode

Now the DeepMergeTwoTypes can recursively call yourself if values are objects or arrays.

type A = [{ key1: string, key2: string }]
type B = [{ key2: null, key3: string }]
const fn = (c: DeepMergeTwoTypes<A, B>) => c[0].
Enter fullscreen mode Exit fullscreen mode

Alt Text

It works perfectly!

Is that all?

Unfortunately not... The last issue with this generic is that there is some problem, with merging Nullable values types together with non-nullable ones.

type A = { key1: string }
type B = { key1: undefined }

type C = DeepMergeTwoTypes<A, B>['key']
Enter fullscreen mode Exit fullscreen mode

Alt Text

We know that the expected output should be string | undefined but it is not. So let’s add another two lines into our if-else chain.

export type DeepMergeTwoTypes<T, U> =
  [T, U] extends [(infer TItem)[], (infer UItem)[]]
    ? DeepMergeTwoTypes<TItem, UItem>[]
    : [T, U] extends [{ [key: string]: unknown}, { [key: string]: unknown } ]
      ? MergeTwoObjects<T, U>
      // ----- 2 added lines ------
      // this line ⏬
      : [T, U] extends [{ [key: string]: unknown } | undefined, { [key: string]: unknown } | undefined ]
        // ... and this line ⏬
        ? MergeTwoObjects<NonNullable<T>, NonNullable<U>> | undefined
          : T | U
Enter fullscreen mode Exit fullscreen mode

Let’s test nullable values merging:


type A = { key1: string }
type B = { key1: undefined }


const fn = (c: DeepMergeTwoTypes<A, B>) => c.key1;
Enter fullscreen mode Exit fullscreen mode

Alt Text

And…. That’s all!!! 🎉

We did it and values are correctly merged even for nullable values, nested objects, and arrays.

Let’s try it on some more complex data

type A = { key1: { a: { b: 'c'} }, key2: undefined }
type B = { key1: { a: {} }, key3: string }


const fn = (c: DeepMergeTwoTypes<A, B>) => c.
Enter fullscreen mode Exit fullscreen mode

Alt Text

Alt Text

Alt Text

full source code

/**
 * Take two objects T and U and create the new one with uniq keys for T a U objectI
 * helper generic for `DeepMergeTwoTypes`
 */
type GetObjDifferentKeys<T, U> = Omit<T, keyof U> & Omit<U, keyof T>
/**
 * Take two objects T and U and create the new one with the same objects keys
 * helper generic for `DeepMergeTwoTypes`
 */
type GetObjSameKeys<T, U> = Omit<T | U, keyof GetObjDifferentKeys<T, U>>
type MergeTwoObjects<T, U> =
  // non shared keys are optional
  Partial<GetObjDifferentKeys<T, U>>
  // shared keys are recursively resolved by `DeepMergeTwoTypes<...>`
  & {[K in keyof GetObjSameKeys<T, U>]: DeepMergeTwoTypes<T[K], U[K]>}
// it merge 2 static types and try to avoid of unnecessary options (`'`)
export type DeepMergeTwoTypes<T, U> =
  // check if generic types are arrays and unwrap it and do the recursion
  [T, U] extends [(infer TItem)[], (infer UItem)[]]
    ? DeepMergeTwoTypes<TItem, UItem>[]
    // check if generic types are objects
    : [T, U] extends [{ [key: string]: unknown}, { [key: string]: unknown } ]
      ? MergeTwoObjects<T, U>
      : [T, U] extends [{ [key: string]: unknown } | undefined, { [key: string]: unknown } | undefined ]
        ? MergeTwoObjects<NonNullable<T>, NonNullable<U>> | undefined
          : T | U

// test cases:
type A = { key1: { a: { b: 'c'} }, key2: undefined }
type B = { key1: { a: {} }, key3: string }


const fn = (c: DeepMergeTwoTypes<A, B>) => c.key
Enter fullscreen mode Exit fullscreen mode

Pst… Have you got

One last question…?

How to modify DeepMergeTwoTypes<T, U> generic to take N arguments instead of just two?

(I will keep it to another article but you can find working source on my gist

https://gist.github.com/Svehla/2c399396cb678503990837c0c997c0db

Preview:
Alt Text

🎉🎉🎉🎉🎉

Discussion

pic
Editor guide
Collapse
faiwer profile image
Stepan Zubashev

Great article. Thank you for it! The more TS recepies we have the better types we can write. Especially thanks for this hack with a tuple and if-else.

One note: There's type PropertyKey. It'd be better to use it instead of string. But... TS dissalow it. It even dissallows using string | number. Hm...

P.S. a translated this article to Russian there ( habr.com/en/post/526998/ ).

Collapse
svehla profile image
Jakub Švehla Author

Hi dude

Thanks a lot for the translation 😇 a read the comments below and that pretty nice 💪
some guy found issue with merging inconsistent arrays [{ a: 'a' }, { b: 'b'}]... I know about it and I already resolved it... but the solution is too complicated (and large) to keep it in one article... So I have a plan to do the second part of it where I'll resolve this edge-case + Add the optional length of types to merge... something like: DeepMergeMany<A, B, C, D, ...>

So I'll see 😏