3

I write a merge function that merge multi objects and return.

type A = { a: number };
type B = { b: number };
type C = { c: number };

const a: A = { a: 1 };
const b: B = { b: 2 };
const c: C = { c: 3 };

function merge<T extends any[]>(...args: T): { [k in keyof T]: T[k] } {
    return args.reduce((previous, current) => {
        return Object.assign(previous, current);
    });
}

const m = merge(a, b, c);

m.a;
m.b;
m.c;

What I expect for type of m is A & B & C, but I got [A, B, C] in compiler, and it give me the error.

Property 'a' does not exist on type '[A, B, C]'.ts(2339)

Is there a right way to declare the return type of my merge function?

pilaoda
  • 45
  • 4

3 Answers3

2

What you need is an intersection of the elements of the array:

TS Playground

type ArrayIntersection<T extends readonly unknown[]> = T extends [infer Head, ...infer Rest] ?
  Head & ArrayIntersection<Rest>
  : unknown;

function merge <T extends readonly any[]>(...args: T): ArrayIntersection<T> {
  return args.reduce((previous, current) => {
    return Object.assign(previous, current);
  });
}

type A = { a: number };
type B = { b: number };
type C = { c: number };

declare const a: A;
declare const b: B;
declare const c: C;

const m = merge(a, b, c); // A & B & C

m.a; // number
m.b; // number
m.c; // number

Note that intersecting types which are incompatible (or have members whose types are incompatible) will result in never for that type:

declare const result: ArrayIntersection<[
  { a: string },
  { b: number },
  { person: { first: string } },
  { person: { last: string } },
  { b: string },
]>;

result.person; // { first: string; last: string; }
result.a; // string
result.b; // never
jsejcksn
  • 27,667
  • 4
  • 38
  • 62
  • 1
    That's a clever way to do an intersection of array like type. – Alex Wayne Dec 27 '21 at 20:03
  • @AlexWayne I'm _pretty sure_ I discovered that recursive technique in one of [Aleksey](https://stackoverflow.com/users/1113002/aleksey-l)'s answers. (But I'm not sure which one, or I'd definitely link to it.) – jsejcksn Dec 27 '21 at 21:05
  • Thanks! I like this recursive grammar, it inspires me more ideas! – pilaoda Dec 28 '21 at 13:08
1

T[number] will produce a union of the types in the args array, and then you can use a Union to Instersection type to merge the members of that union into an intersection:

type UnionToIntersection<U> = 
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

function merge<T extends any[]>(...args: T): UnionToIntersection<T[number]> {
    return args.reduce((previous, current) => {
        return Object.assign(previous, current);
    });
}

Playground

Alex Wayne
  • 178,991
  • 47
  • 309
  • 337
  • I read the linked answer that you copied the utility type from, but I still don't understand how it works. Can you break it down step by step? – jsejcksn Dec 27 '21 at 19:48
  • Alas, I cannot. It's something about converting a type from a covariant to contravariant position, which changes how the union is considered. Or perhaps vice versa? If @jcalz says it's "evil magic" you can probably take it for granted that it works. You can probably learn some advanced stuff by diving into it though, to which I say "good luck!" – Alex Wayne Dec 27 '21 at 19:54
  • Note that `const oops = merge(Math.random() < 0.5 ? { a: "a" } : { b: "b" }, { c: "c" })` will produce `never` with a straight union-to-intersection, since this approach doesn't distinguish between unions arising from multiple elements and those already present in a single element. – jcalz Dec 27 '21 at 20:28
  • 1
    @jsejcksn yeah it's a bunch of type juggling (similar to my answer here also). When you `infer` a single type from multiple types in a normal *covariant* type position you get a union. But if the types are in a *contravariant* type position you get an intersections. (see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#type-inference-in-conditional-types )Functions are contravariant in their parameter types (e.g., a union of functions can only accept an *intersection* of the parameters in order to be type safe), and gives us what we want. I hit the character limi – jcalz Dec 27 '21 at 20:45
  • @jcalz Thanks! This is the work I did to explore for myself before you explained: https://tsplay.dev/w6Xlvm – jsejcksn Dec 27 '21 at 21:09
  • Thanks for your answer! I will take a note of this type. – pilaoda Dec 28 '21 at 13:08
1

If you want a non-recursive way to get an intersection of all the elements of a tuple type, you can do it like this:

type TupleToIntersection<T extends readonly any[]> =
    { [N in keyof T]: (x: T[N]) => void }[number] extends
    (x: infer I) => void ? I : never;

It works via some type juggling that is easier to write than to explain (but I'll try): First we map the tuple T to a new version where each element at numeric index N (that is, T[N]) has been placed in a contravariant position (as the parameter to a function, (x: T[N]) => void), and then index into that tuple with number to get a union of those functions. From this we use conditional type inference to infer a single function parameter type for that union of functions, which (as mentioned in the linked doc) produces an intersection I.

Whether that makes sense or not, you can observe that it works:

type ABC = TupleToIntersection<[A, B, C]>
// type ABC = A & B & C

type AorBandC = TupleToIntersection<[A | B, C]>
// type AorBandC = (A | B) & C

Note that the compiler preserves that A | B union in the second example.


Anyway that means we can write the output of merge() as TupleToIntersection<T>:

function merge<T extends any[]>(...args: T): TupleToIntersection<T> {
    return args.reduce((previous, current) => {
        return Object.assign(previous, current);
    });
}

And it produces the expected output:

const m = merge(a, b, c);
// const m: A & B & C
m.a;
m.b;
m.c;

const m2 = merge(Math.random() < 0.5 ? a : b, c)
// const m2: (A | B) & C

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360