缩小相互依赖的、基于联合的、通用函数参数的类型

Narrowing down types of co-dependent, union-based, generic function arguments

我有一个简单的函数,它检查 variable 是否包含在数组 opts

type IType1 = 'text1' | 'text2';
type IType2 = 'text3' | 'text4' | 'text5';

const foo: IType2 = 'text4';

function oneOf(variable, opts) {
    return opts.includes(variable);
}

我想要的是让 optsvariable 相互依赖,所以如果我调用函数:

oneOf(foo, ['text3', 'text5']) //=> I would get OK
oneOf(foo, ['text3', 'text2']) //=> I would get a warning here, because `IType2` (type of `foo`) does not contain 'text2'

方法一

如果我写:

function oneOf<T extends IType1 | IType2>(variable: T, opts: T[]): boolean{
    return opts.includes(variable);
}

在这两种情况下我都会得到 OK。 TS 会简单地假设在第二种情况下 T extends "text3" | "text4" | "text2",这不是我想要的。

方法二

如果我写

function oneOf<T1 extends IType1 | IType2, T2 extends T1>(variable: T1, opts: T2[]): boolean{
    return opts.includes(variable);
}

我会得到一个错误:

Argument of type 'T1' is not assignable to parameter of type 'T2'.
  'T1' is assignable to the constraint of type 'T2', but 'T2' could be instantiated with a 
different subtype of constraint '"text1" | "text2" | "text3" | "text4" | "text5"'.
...

这完全可以在 TS 中完成吗?

constraint T extends IType1 | IType2 is that IType1 and IType2 are themselves unions of string literal types 的问题。编译器会将其压缩为 T extends 'text1' | 'text2' | 'text3' | 'text4' | 'text5',因此编译器不会自然地按照 IType1IType2 对它们进行分组。如果您传入 'text4' 类型的值,那么编译器将推断 T'text4' 而不是 IType2.

所以有不同的方法来解决这个问题。一种是您可以将联合类型保留在 variable 类型的约束中,但对 opts 的元素类型使用 conditional type:

function oneOf<T extends IType1 | IType2>(
    variable: T,
    opts: Array<T extends IType1 ? IType1 : IType2>
): boolean {
    return (opts as readonly (IType1 | IType2)[]).includes(variable);
}
oneOf('text4', ['text3', 'text5']) // okay
oneOf('text4', ['text3', 'text2']) // error

条件类型 T extends IType1 ? IType1 : IType2 的效果是 T 扩大到 IType1IType2。请注意,该函数的实现至少需要一个 type assertion,因为编译器实际上不能对未解析的泛型类型做太多事情......因为它不知道 T 是什么,所以它不能'不知道 T extends IType1 ? IType1 : IType2 是什么;这种类型对编译器来说本质​​上是不透明的,因为评估是延迟的。通过说 optsreadonly (IType1 | IType2)[] 我们只是说无论 opts 是什么,我们都能够从中读取 IType1IType2 类型的元素。


另一种方法是放弃泛型,只考虑 IType1IType2 的不同调用签名。传统上你会用 overloads 这样做:

function oneOf(variable: IType1, opts: IType1[]): boolean;
function oneOf(variable: IType2, opts: IType2[]): boolean;
function oneOf(variable: IType1 | IType2, opts: readonly (IType1 | IType2)[]) {
    return opts.includes(variable);
}
oneOf('text4', ['text3', 'text5']); // okay
oneOf('text4', ['text3', 'text2']); // error

这很好,但是使用重载可能有点困难,而且它们的扩展性不是很好(如果你有 IType1 | IType2 | IType3 | ... | IType10 写出调用签名可能会很烦人)。

当每个调用签名的 return 类型相同(例如它们都是 boolean)时,重载的替代方法是使用一个调用签名,该调用签名采用 rest parameter whose type is a union of tuples :

function oneOf(...[variable, opts]:
    [variable: IType1, opts: IType1[]] |
    [variable: IType2, opts: IType2[]]
): boolean {
    return (opts as readonly (IType1 | IType2)[]).includes(variable);
}
oneOf('text4', ['text3', 'text5']); // okay
oneOf('text4', ['text3', 'text2']); // error

这实际上看起来很像调用方的过载。从那里你可以制作一个编程版本来为我们计算元组的并集:

type ValidArgs<T extends any[]> = {
    [I in keyof T]: [variable: T[I], opts: T[I][]]
}[number];

function oneOf(...[variable, opts]: ValidArgs<[IType1, IType2]>): boolean {
    return (opts as readonly (IType1 | IType2)[]).includes(variable);
}
oneOf('text4', ['text3', 'text5']); // okay
oneOf('text4', ['text3', 'text2']); // error

这和以前一样; ValidArgs<[IType1, IType2]> 的计算结果为 [variable: IType1, opts: IType1[]] | [variable: IType2, opts: IType2[]]。它的工作原理是将输入元组类型 [IType1, IType2]mapping over it to form a new type like [[variable: IType1, opts: IType1[]], [variable: IType2, opts: IType2[]]] which we then immediately index intonumber 索引一起获取该元组元素的并集;即 [variable: IType1, opts: IType1[]] | [variable: IType2, opts: IType2[]].

您可以看到 ValidArgs 如何更容易扩展:

type Test = ValidArgs<[0, 1, 2, 3, 4, 5]>;
// type Test = [variable: 0, opts: 0[]] | [variable: 1, opts: 1[]] | [variable: 2, opts: 2[]] | 
//   [variable: 3, opts: 3[]] | [variable: 4, opts: 4[]] | [variable: 5, opts: 5[]]

无论如何,根据您的用例,所有这些版本在调用方都应该工作得相当好。

Playground link to code