如何声明可变数量的参数类型的交集类型

How to declare the intersection type of variable amount of argument types

我有一个函数 merge,它接受可变数量的对象作为参数,并创建一个新对象,其类型是参数类型的交集,有点像 Object.assign(),所以 merge({ a: 1 }, { b: 2 }) 将 return 类型为 { a: number, b: number} 的对象。

想象一下这样的实现:

function merge(...objects) {
  // Something like
  return Object.assign({}, ...objects)
}

我正在寻找一种声明其类型的方法,该方法允许我将类型信息保存在 returned 对象中。

我只能想象像这样重载定义:

function merge<A>(a: A): A;
function merge<A, B>(a: A, b: B): A & B;
function merge<A, B, C>(a: A, b: B, c: C): A & B & C;
// etc.

这不太方便,因为预计将使用超过五个参数调用此函数。

谢谢!

您可以使用单个类型参数 T 对应于其所有元素类型的 tuple type of the rest parameter to merge(). Then you need to come up with a way to take a tuple type and calculate the intersection。我们称该类型函数为 IntersectArrayElements<T>。所以 merge() 看起来像:

function merge<T extends any[]>(
  ...objects: T
): IntersectArrayElements<T> {
  return Object.assign({}, ...objects)
}

现在我们只需实施 IntesectArrayElements<T>。有几种方法可以做到这一点:


一个相对简单的实现是写一个 recursive conditional type that uses variadic tuple types:

type IntersectArrayElements<T extends any[]> =
  T extends [infer F, ...infer R] ? F & IntersectArrayElements<R> : unknown;

它将元组T拆分为它的第一个元素F和元组的其余部分R,然后结果F与[=25相交=],如果 T 已经是空的(或者是数组而不是元组),那么它会生成 the unknown type,它会被吸收到所有交叉点中。


如果您有很长的元组,上述实现可能会达到递归限制(大约 25、50 或 100 个元素)。对 TypeScript 4.5+ 的上述改进是将其重写为 tail-recursive type function,TypeScript 可以更有效地处理最多 1000 个元素的元组:

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

这基本上是相同的算法,除了它在累加器类型参数中收集结果 A


递归实现可能是最容易理解的,但递归可能有点昂贵,尤其是与其他本身可能是递归的类型函数一起使用时。如果你可以在没有递归的情况下实现某些东西,那么最好这样做。并且有一种不用递归来写 IntersectArrayElements<T> 的方法,尽管是以一种不太明显的方式:

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

这首先在它们的参数类型中 mapping the tuple into a new form where each element becomes the parameter type to a function. So [{a: string}, {b: number}] becomes [(x: {a: string})=>void, (x: {b: number})=>void]. Then we index into it with number to get a union of these functions... so now we'd have ((x: {a: string})=>void)|((x: {b: number})=>void). And from this we use conditional type inference to infer a single parameter type for that union of functions. Because function types are contravariant 起作用,编译器推断出 交集 而不是并集...所以现在我们有 {a: string} & {b: number} .简单吧?


对于这三种实现中的任何一种,merge() 都会出现以下行为:

const a = { a: "hello" };
const b = { b: 123 };
const c = { c: true };

const mA = merge(a); // {a: string}
const mAB = merge(a, b); // {a: string} & {b: number}
const mABC = merge(a, b, c); // {a: string} & {b: number} & {c: boolean}
const mAandBorC = 
  merge(a, Math.random() < 0.5 ? b : c) // {a: string} & ({b: number} | {c: boolean})

这看起来就是你想要的,万岁!


一个小警告:当您使用一组编译器已知其顺序和类型的参数调用 merge() 时,这会很好地工作。如果编译器无法计算出参数的确切数量和顺序,那么您可能会得到不太理想的输出类型:

const forgotOrder = [a, b];
const mOops = merge(...forgotOrder);

这里,forgotOrder推断出的类型是无序数组类型Array<{a: string} | {b: number}>。根据我们称为 merge() 的类型,编译器不知道 forgotOrder 中有多少元素,也不知道 {a: string}{b: number} 元素是否都存在。因此 mOops 的类型是 IntersectArrayElements<Array<{a: string} | {b: number}>>,它要么是 {a: string} | {b: number} 本身,要么可能是 unknown,具体取决于您在上面使用的实现方式。不清楚这种奇怪的调用应该 是什么类型,所以这就是为什么我说这是次要的。可以尝试加强 merge() 以应对这种情况,但这可能超出了问题的范围。

Playground link to code