TS4.1+ 更新
原来的答案仍然有效(如果需要解释,您应该阅读它),但现在支持 recursive conditional types,我们可以将 merge() 写成可变参数:
type OptionalPropertyNames<T> =
{ [K in keyof T]-?: ({} extends { [P in K]: T[K] } ? K : never) }[keyof T];
type SpreadProperties<L, R, K extends keyof L & keyof R> =
{ [P in K]: L[P] | Exclude<R[P], undefined> };
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never
type SpreadTwo<L, R> = Id<
& Pick<L, Exclude<keyof L, keyof R>>
& Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
& Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
& SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
>;
type Spread<A extends readonly [...any]> = A extends [infer L, ...infer R] ?
SpreadTwo<L, Spread<R>> : unknown
type Foo = Spread<[{ a: string }, { a?: number }]>
function merge<A extends object[]>(...a: [...A]) {
return Object.assign({}, ...a) as Spread<A>;
}
你可以测试一下:
const merged = merge(
{ a: 42 },
{ b: "foo", a: "bar" },
{ c: true, b: 123 }
);
/* const merged: {
a: string;
b: number;
c: boolean;
} */
Playground link to code
原始答案
TypeScript standard library definition of Object.assign() 生成的交集类型是 approximation,它不能正确表示如果后面的参数具有与前面的参数同名的属性会发生什么。不过,直到最近,这是您在 TypeScript 的类型系统中所能做的最好的事情。
从在 TypeScript 2.8 中引入 conditional types 开始,但是,您可以使用更接近的近似值。其中一项改进是使用类型函数Spread<L,R> 定义here,如下所示:
// Names of properties in T with types that include undefined
type OptionalPropertyNames<T> =
{ [K in keyof T]: undefined extends T[K] ? K : never }[keyof T];
// Common properties from L and R with undefined in R[K] replaced by type in L[K]
type SpreadProperties<L, R, K extends keyof L & keyof R> =
{ [P in K]: L[P] | Exclude<R[P], undefined> };
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never // see note at bottom*
// Type of { ...L, ...R }
type Spread<L, R> = Id<
// Properties in L that don't exist in R
& Pick<L, Exclude<keyof L, keyof R>>
// Properties in R with types that exclude undefined
& Pick<R, Exclude<keyof R, OptionalPropertyNames<R>>>
// Properties in R, with types that include undefined, that don't exist in L
& Pick<R, Exclude<OptionalPropertyNames<R>, keyof L>>
// Properties in R, with types that include undefined, that exist in L
& SpreadProperties<L, R, OptionalPropertyNames<R> & keyof L>
>;
(我稍微更改了链接定义;使用标准库中的Exclude 而不是Diff,并用无操作Id 类型包装Spread 类型以使检查的类型更易于处理比一堆十字路口)。
让我们试试吧:
function merge<A extends object, B extends object>(a: A, b: B) {
return Object.assign({}, a, b) as Spread<A, B>;
}
const merged = merge({ a: 42 }, { b: "foo", a: "bar" });
// {a: string; b: string;} as desired
您可以看到输出中的a 现在被正确识别为string 而不是string & number。耶!
但请注意,这仍然是一个近似值:
-
Object.assign() 仅复制enumerable, own properties,类型系统无法为您提供任何方式来表示要过滤的属性的可枚举性和所有权。这意味着merge({},new Date()) 看起来像 TypeScript 的类型Date,即使在运行时不会复制任何Date 方法并且输出本质上是{}。这是目前的硬性限制。
-
此外,Spread 的定义在缺少 属性和存在未定义值 的属性之间并不是真正的distinguish。所以merge({ a: 42}, {a: undefined}) 被错误地输入为{a: number} 而它应该是{a: undefined}。这可能可以通过重新定义Spread 来解决,但我不是 100% 确定。对于大多数用户来说,这可能不是必需的。 (编辑:这可以通过重新定义type OptionalPropertyNames<T> = { [K in keyof T]-?: ({} extends { [P in K]: T[K] } ? K : never) }[keyof T]来解决)
-
类型系统不能对它不知道的属性做任何事情。 declare const whoKnows: {}; const notGreat = merge({a: 42}, whoKnows); 在编译时将有一个{a: number} 的输出类型,但如果whoKnows 恰好是{a: "bar"}(可分配给{}),那么notGreat.a 在运行时是一个字符串,但在运行时是一个数字编译时间。哎呀。
所以请注意;将Object.assign() 输入为交集或Spread<> 是一种“尽力而为”的事情,在极端情况下可能会导致您误入歧途。
Playground link to code
*注意:Id<T> 是一种身份类型,原则上不应对该类型做任何事情。有人在某个时候编辑了此答案以将其删除并仅替换为T。这样的更改并不正确,确切地说,但它违背了目的......即遍历键以消除交叉点。比较:
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never
type Foo = { a: string } & { b: number };
type IdFoo = Id<Foo>; // {a: string, b: number }
如果您检查IdFoo,您会看到交集已被消除,两个成分已合并为一个类型。同样,Foo 和 IdFoo 在可分配性方面没有真正的区别。只是后者在某些情况下更容易阅读。