TypeScript Type Merging TypeScript Type Merging typescript typescript

TypeScript Type Merging


TLDR: Magic! Try the Playground

So, this is a tricky question. Not so much because of the merge requirements, but because of the edge cases. Getting the low hanging fruit took <20 minutes. Making sure it works everywhere took a couple more hours... and tripled the length. Unions are tricky!

  1. What is an optional property? In { a: 1 | undefined, b?: 1 } is a an optional property? Some people say yes. Others no. Personally, I only include b in the optional list.

  2. How do you handle unions? What is the output of Merge<{}, { a: 1} | { b: 2 }>? I think the type that makes the most sense is { a?: 1 } | { b?: 2 }. What about Merge<string, { a: 1 }>? If you don't care at all about unions, this is easy... if you do, then you have to consider all these. (What I chose in parens)

    1. Merge<never, never> (never)
    2. Merge<never, { a: 1 }> ({ a?: 1 })
    3. Merge<string, { a: 1 }> (string | { a?: 1 })
    4. Merge<string | { a: 1 }, { a: 2 }> (string | { a: 1 | 2 })

Let's figure out this type, starting with the helpers.

I had an inkling as soon as I thought about unions that this type was going to become complex. TypeScript doesn't have a nice builtin way to test type equality, but we can write a helper type that causes a compiler error if two types aren't equal.

(Note: The Test type could be improved, it could allow types to pass that aren't equivalent, but it is sufficient for our uses here while remaining pretty simple)

type Pass = 'pass';type Test<T, U> = [T] extends [U]    ? [U] extends [T]        ? Pass        : { actual: T; expected: U }    : { actual: T; expected: U };function typeAssert<T extends Pass>() {}

We can use this helper like this:

// try changing Partial to RequiredtypeAssert<Test<Partial<{ a: 1 }>, { a?: 1 }>>();

Next, we'll need two helper types. One to get all required keys of an object, and one to get the optional keys. First, some tests to describe what we are after:

typeAssert<Test<RequiredKeys<never>, never>>();typeAssert<Test<RequiredKeys<{}>, never>>();typeAssert<Test<RequiredKeys<{ a: 1; b: 1 | undefined }>, 'a' | 'b'>>();typeAssert<Test<OptionalKeys<never>, never>>();typeAssert<Test<OptionalKeys<{}>, never>>();typeAssert<Test<OptionalKeys<{ a?: 1; b: 1, c: undefined }>, 'a'>>();

There are two things to note here. First, *Keys<never> is never. This is important because we will be using these helpers in unions later, and if the object is never it shouldn't contribute any keys. Second, none of these tests include union checks. Considering how important I said unions were, this might surprise you. However, these types are only used after all unions are distributed, so their behavior there doesn't matter (though if you include these in your project, you might want to look at said behavior, it is different that you'd probably expect for RequiredKeys due to how its written)

These types pass the given checks:

type OptionalKeys<T> = {    [K in keyof T]-?: T extends Record<K, T[K]> ? never : K;}[keyof T;type RequiredKeys<T> = {    [K in keyof T]-?: T extends Record<K, T[K]> ? K : never;}[keyof T] & keyof T;

Couple notes about these:

  1. Use -? to remove optionality of properties, this lets us avoid a wrapper of Exclude<..., undefined>
  2. T extends Record<K, T[K]> works because { a?: 1 } does not extend { a: 1 | undefined }. I went through a few iterations before finally settling on this. You can also detect optionality with another mapped type as jcalz does here.
  3. In version 3.8.3, TypeScript can correctly infer that the return type of OptionalKeys is assignable to keyof T. It cannot, however, detect the same for RequiredKeys. Intersecting with keyof T fixes this.

Now that we have these helpers, we can define two more types that represent your business logic. We need RequiredMergeKeys<T, U> and OptionalMergeKeys<T, U>.

type RequiredMergeKeys<T, U> = RequiredKeys<T> & RequiredKeys<U>;type OptionalMergeKeys<T, U> =    | OptionalKeys<T>    | OptionalKeys<U>    | Exclude<RequiredKeys<T>, RequiredKeys<U>>    | Exclude<RequiredKeys<U>, RequiredKeys<T>>;

And some tests to make sure these behave as expected:

typeAssert<Test<OptionalMergeKeys<never, {}>, never>>();typeAssert<Test<OptionalMergeKeys<never, { a: 1 }>, 'a'>>();typeAssert<Test<OptionalMergeKeys<never, { a?: 1 }>, 'a'>>();typeAssert<Test<OptionalMergeKeys<{}, {}>, never>>();typeAssert<Test<OptionalMergeKeys<{ a: 1 }, { b: 2 }>, 'a' | 'b'>>();typeAssert<Test<OptionalMergeKeys<{}, { a?: 1 }>, 'a'>>();typeAssert<Test<RequiredMergeKeys<never, never>, never>>();typeAssert<Test<RequiredMergeKeys<never, {}>, never>>();typeAssert<Test<RequiredMergeKeys<never, { a: 1 }>, never>>();typeAssert<Test<RequiredMergeKeys<{ a: 0 }, { a: 1 }>, 'a'>>();

Now that we have these, we can define the merge of two objects, ignoring primitives and unions for the moment. This calls the top level Merge type that we haven't defined yet to handle primitives and unions of the members.

type MergeNonUnionObjects<T, U> = {    [K in RequiredMergeKeys<T, U>]: Merge<T[K], U[K]>;} & {    [K in OptionalMergeKeys<T, U>]?: K extends keyof T        ? K extends keyof U            ? Merge<Exclude<T[K], undefined>, Exclude<U[K], undefined>>            : T[K]        : K extends keyof U        ? U[K]        : never;};

(I didn't write specific tests here because I had them for the next level up)

We need to handle both unions and non-objects. Let's handle unions of objects next. Per the discussion earlier, we need to distribute over all types and merge them individually. This is pretty straightforward.

type MergeObjects<T, U> = [T] extends [never]    ? U extends any        ? MergeNonUnionObjects<T, U>        : never    : [U] extends [never]    ? T extends any        ? MergeNonUnionObjects<T, U>        : never    : T extends any    ? U extends any        ? MergeNonUnionObjects<T, U>        : never    : never;

Note that we have extra checks for [T] extends [never] and [U] extends [never]. This is because never in a distributive clause is like for (let i = 0; i < 0; i++), it will never enter the "body" of the conditional and will therefore return never, but we only want never if both types are never.

We're almost there! We can now handle merging objects, which is the hardest part of this problem. All that's left is to handle primitives, which we can do by just forming a union of all possible primitives and excluding primitives to the types passed to MergeObjects.

type Primitive = string | number | boolean | bigint | symbol | null | undefined;type Merge<T, U> =    | Extract<T | U, Primitive>    | MergeObjects<Exclude<T, Primitive>, Exclude<U, Primitive>>;

And with that type, we're done! Merge behaves as desired, in only 50 or so lines of uncommented insanity.

... or are we? @petroni mentioned in the comments that this type doesn't play well with arrays that are present in both objects. There are a few different ways to handle this, particularly because TypeScript's tuple types have become increasingly flexible. Properly merging [1, 2] and [3] should probably give [1 | 3, 2?]... but doing that is at least as complicated as what we've already done. A much simpler solution is to ignore tuples entirely, and always produce an array, so this example would produce (1 | 2 | 3)[].

A final note on produced types:

The resulting type from Merge right now is correct, but it isn't as readable as it could be. Right now hovering over the resulting type will show an intersection and inner objects with have Merge wrapped around them instead of showing the result. We can fix this by introducing an Expand type that forces TS to expand everything into a single object.

type Expand<T> = T extends Primitive ? T : { [K in keyof T]: T[K] };

Now just modify MergeNonUnionObjects to call Expand. Where this is necessary is somewhat trial and error. You can play around with including it, or not, to get a type display that works for you.

type MergeNonUnionObjects<T, U> = Expand<    {        [K in RequiredMergeKeys<T, U>]: Expand<Merge<T[K], U[K]>>;    } & {        [K in OptionalMergeKeys<T, U>]?: K extends keyof T            ? K extends keyof U                ? Expand<Merge<                    Exclude<T[K], undefined>,                    Exclude<U[K], undefined>                >>                : T[K]            : K extends keyof U            ? U[K]            : never;    }>;

Check it out in the playground which includes all the tests I used to validate the results.