1

I've already actually created quite an unoptimised method to do this and I thought it was working until now that I have come back to it, it seems to no longer work (just shows as never - still works on the TS playground for some reason). So far, it looks like this:

//https://github.com/microsoft/TypeScript/issues/13298#issuecomment-707364842
type UnionToArray<T> = (
    (
        (
            T extends any
            ? (t: T) => T
            : never
        ) extends infer U
        ? (U extends any
            ? (u: U) => any
            : never
        ) extends (v: infer V) => any
        ? V
        : never
        : never
    ) extends (_: any) => infer W
    ? [...UnionToArray<Exclude<T, W>>, W]
    : []
);

type IntersectObjectArray<A extends any> = A extends [infer T, ...infer R] ? T & IntersectObjectArray<R> : unknown

type ExpandTopKeys<A extends any> = A extends { [key: string]: infer X } ? { [K in keyof X]: X[K] } : unknown
type Expand<A extends any> = IntersectObjectArray<UnionToArray<ExpandTopKeys<A>>>;

type MergedClasses<C extends object[]> = Expand<IntersectObjectArray<C>>;

And what it does is, given:

X = {
    foo: {
        a: "green",
    },
    bar: {
        b: "blue",
    }
}
Y = {
    extra: {
        c: "orange",
    },
}

MergedClasses<[X, Y]> will return:

{
    a: "green",
    b: "blue",
    c: "orange",
}

So it takes the objects, combines them and then expands the "top level" keys into a single object.

The steps it currently goes through are:

  • Intersect all objects in the array i.e. [X, Y] becomes X & Y
  • Expand the "top keys" i.e. expand foo, bar and extra which converts it into a union like:
{
    a: "green",
    b: "blue",
} | {
    c: "orange",
}
  • Covert the union into an object array i.e. [{ a: "green", b: "blue" }, { c: "orange" }]
  • And finally, again, intersect all of those object together. After doing all of that, the result is what I want. However this feels really unrobust and easily breakable (and to be honest, seems to have already broken).

Is there a simpler way I could merge any number of objects, and expand any of their keys?

DreamingInsanity
  • 167
  • 2
  • 10

1 Answers1

2

Union-to-tuple in TypeScript is a terrible awful fragile type manipulation that nobody in their right mind should even think about doing (link to me doing this). Luckily, you shouldn't have to do that to implement your type function.

The basic operation I see here is that you have some object type (this includes tuples) whose property keys should be ignored (for tuples, this means "0" and "1", etc), and whose property values should be merged. This merging is essentially an intersection, although an single merged object like {a: 1, b: 2, c: 3} is probably preferable to a variadic intersection like {a: 1} & {b: 2} & {c: 3}. Let's call this operation MergeProperties<T>. So MergeProperties<{x: {a: 1}, y: {b: 2}> should be {a: 1, b: 2}, and MergeProperties<[{c: 3}, {d: 4}]> should be {c: 3, d: 4}.

Then, conceptually, what you are doing with MergedClasses<T> is MergeProperties<MergeProperties<T>>; you are merging all the tuple elements, and then merging the properties of what results.


Here is a possible implementation of MergeProperties<T>:

type MergeProperties<T> =
    (({ [K in keyof T]-?: (x: T[K]) => void }[keyof T]) extends
        ((x: infer I) => void) ? I : never
    ) extends infer O ? { [K in keyof O]: O[K] } : never;

Everything before extends infer O ... is performing a variadic intersection on all the properties of T. There is no built-in type function to do this; if you look up all the properties of T like T[keyof T], you get a union and not an intersection. Luckily we can use conditional type inference to turn unions into intesections. See this question for more details, but roughly, a union of functions takes an argument that is the intersection of those function's argument types, due to the contravariance of function types in their argument types.

The extends infer O ... part takes this variadic intersection and converts it to a single object type. See this question for more information.


You can verify that this version works for regular object types:

type Test = MergeProperties<{ x: { a: 1 }, y: { b: 2 } }>;
/* type Test = {
    a: 1;
    b: 2;
} */

but not for tuples:

type Oops = MergeProperties<[{ c: 1 }, { d: 2 }]>;
/* type Oops = never */

This is because of a design limitation or possibly a bug in TypeScript when mapping over array types whereby the mapping or subsequent lookup sometimes operates on all properties of an array type, even those like push() and length; see microsoft/TypeScript#27995 for details.

One could re-write MergeProperties<T> to take care of this when T is a tuple, but it's just as easy to transform the T tuple into something easier to handle: Omit<T, keyof any[]> will turn a T like [{c: 1}, {d: 2}] into {"0": {c: 1}, "1": {d: 2}}. The Omit utility type is used to discard properties by key, and throwing away keyof any[] we are left with only the positional indices.


Therefore, we can define MergedClasses like this:

type MergedClasses<T extends readonly object[]> =
    MergeProperties<MergeProperties<Omit<T, keyof any[]>>>

and verify that it works at least for your example:

type X = {
    foo: {
        a: "green",
    },
    bar: {
        b: "blue",
    }
}
type Y = {
    extra: {
        c: "orange",
    },
}

type Z = MergedClasses<[X, Y]>
/* type Z = {
    a: "green";
    b: "blue";
    c: "orange";
} */

Looks good. There are probably lots of edge cases here. Optional properties, index signatures, unions, et cetera, are likely to do weird things. Your original version probably had similar issues, but they may be different enough that you should make sure to test it on your use cases.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • I probably should have mentioned this in the post but if, for instance, `X` was `{ foo: { a: "red" } }` and `Y` was `{ foo: { a: "green" } }` the expected output should be `{ a: "green" }` whereas it is currently `never` with the above solution. I'll give it a shot too but could it be fixed by removing any duplicate keys? I.E if `foo` exists in `Y` remove it from `X`. – DreamingInsanity May 12 '21 at 09:41
  • Note that your original version should also do the same kind of `never` thing, since it begins by intersecting the tuple elements together. As you mentioned, having the edge cases you care about represented in an [mcve] in the post is recommended; otherwise this sort of follow-up looks like scope creep and not clarification. Anyway, given the example in your comment, I'd probably rewrite it like [this](https://tsplay.dev/w26qxw) so that we iterate over the tuple elements instead of intersecting them indiscriminately. – jcalz May 12 '21 at 12:59
  • I found another minor edge case that caused a weird issue but I managed to fix it. It also allowed me to understand how you code works and it's quite interesting. You can see the fix [here](https://cutt.ly/vbF1KZj). Thanks for all the help! – DreamingInsanity May 12 '21 at 15:40