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
Prerequisite
If you want to deep dive into advanced typescript types I recommend this typescript series full of useful examples.
Basic static types inferring: https://dev.to/svehla/typescript-inferring-stop-writing-tests-avoid-runtime-errors-pt1-33h7
More advanced generics https://dev.to/svehla/typescript-generics-stop-writing-tests-avoid-runtime-errors-pt2-2k62
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.
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
As you can see type A
define key2
as a string but type B
define key2
as a null
value.
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
}
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>
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>['']
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>>
The returned type is an object.
type A = { key1: string, key2: string }
type B = { key2: null, key3: string }
type C = GetObjSameKeys<A, B>
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] }
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.
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
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.
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)[]>
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 ...
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].
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']
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
Let’s test nullable values merging:
type A = { key1: string }
type B = { key1: undefined }
const fn = (c: DeepMergeTwoTypes<A, B>) => c.key1;
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.
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
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
🎉🎉🎉🎉🎉
Discussion
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 usingstring | number
. Hm...P.S. a translated this article to Russian there ( habr.com/en/post/526998/ ).
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 😏