Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TypeScript Type Merging

I have a case where I want to "merge" types where the default type joining (i.e. T | U or T & U) doesn't achieve what I want.

What I am trying to make is a deep and smart type merge that will automatically mark properties as optional during the merge and perform deep merges of TypeScript interfaces/types.

To give an example, assume we have types A and B.

type A = {
  a: string;
  b: number;
  c: boolean;
  d: {
    a2: string;
    b2: number;
  };
  e?: number;
};

type B = {
  a: string;
  b: boolean;
  d: {
    a2: string;
    c2: boolean;
  };
};

I am looking for a Merge function that would accept 2 generic types

type Merge<T, U> = ?????;

Then, if used on types A and B, the output would be as follows

type AB = {
  a: string;
  b: number | boolean;
  c?: boolean;
  d: {
    a2: string;
    b2?: number;
    c2?: boolean;
  };
  e?: number;
};

As this shows, the Merge type would perform the following logic:

  1. If the property exists on both T and U and is an identical type, mark it as required and be set to the type in both T/U (like what occurred with property a).
  2. If the property exists on both T and U but is a different type, mark it as required and set to a union type if it is a primitive (like what occurred with property b) or do a recursive merge if it is an object (like what occurred with property d).
  3. If the property exists on one type but not another, mark the property as optional and set it to the type that it was in the input type where it actually existed (like what occurred with property c as well as b2 and c2).
  4. If the property is already optional in one type, it should be optional on the output type with the existing rules above applied for determining its value (like what occurred with property e)

Assume that you can use recursive conditional types although I recognize that they aren't yet officially supported and should not be used in production. I can make an unrolled version similar to jcalz@'s solution here for production use cases.

Here is a playground set up for the question to test with.

like image 524
Reed Hermes Avatar asked Mar 22 '20 02:03

Reed Hermes


1 Answers

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 Required
typeAssert<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.

like image 186
Gerrit0 Avatar answered Sep 27 '22 20:09

Gerrit0