如何根据另一个函数参数的缩小类型来推断/缩小函数参数类型



在我的应用程序中,有两种颜色格式:十六进制和rgb。十六进制颜色存储为字符串,rgb颜色存储为符合以下接口的对象:{ r: number; g: number; b: number; a: number }。我的类型定义如下:

type Color<T> =
T extends 'hex' ? string :
T extends 'rgb' ? ColorRgb :
never
type ColorRgb = { r: number; g: number; b: number; a: number }
// Infer color value type via color format
type T0 = Color<'hex'> // string ✅
type T1 = Color<'rgb'> // ColorRgb ✅

这里,T0T1是按预期推断的。

问题陈述

我在编写一个函数时遇到了问题,其中一个参数是根据另一个参数的值缩小的。下面的代码是我的起点。它是一个使用formatvalue参数的函数。它到底做什么并不重要。

我想要实现的是:如果我缩小format参数的值(例如,检查是否为format === 'hex'的If语句),我希望TypeScript以某种方式推断value参数的类型。当format === 'hex'时,value的类型应为string;当format === 'rgb'时,value应当是类型ColorRgb

function processColor (format: 'hex' | 'rgb', value: string | ColorRgb) {
if (format === 'hex') {
console.log(format, value) // format: 'hex' ✅, value: string | ColorRgb ❌
} else {
console.log(format, value) // format: 'rgb' ✅, value: string | ColorRgb ❌
}
}

方法1:功能过载

我采用的一种方法是使用函数重载。

function processColor2 (format: 'hex', value: string);
function processColor2 (format: 'rgb', value: ColorRgb);
function processColor2 (format: 'hex' | 'rgb', value: string | ColorRgb) {
if (format === 'hex') {
console.log(format, value) // format: 'hex' ✅, value: string | ColorRgb ❌
} else {
console.log(format, value) // format: 'rgb' ✅, value: string | ColorRgb ❌
}
}

在我看来,函数重载是一种专门使调用函数更具限制性的方法,因为这种实现根本不会改变我的目标。

方法2:带约束的通用函数

我研究的另一种方法是使用带有约束的泛型函数。

function processColor3<T extends 'hex' | 'rgb'> (format: T, value: Color<T>) {
if (format === 'hex') {
console.log(format, value) // format: T extends 'hex' | 'rgb' ❌, value: Color<T> ❌
} else {
console.log(format, value) // format: T extends 'hex' | 'rgb' ❌, value: Color<T> ❌
}
}

在这里,我真的很惊讶地发现我在函数定义中丢失了类型信息。尽管在if分支中将format参数缩小为'hex',但其类型现在报告为T extends 'hex' | 'rgb'。我可能误解了条件类型到底能完成什么,或者我可能使用不正确。


如何告诉TypeScript根据另一个函数参数的缩小类型来推断函数参数类型?

正如您所注意到的,重载和泛型条件类型实际上只适用于保证函数的调用者的类型安全。在函数的实现内部,编译器会丢失维护类型安全所需的信息。

对于过载,这是故意的;实现者的责任是满足调用签名的约定,编译器并没有真正尝试验证它。在microsoft/TypeScript#13235上有人建议对此进行更改,但由于过于复杂而被关闭。

对于泛型条件类型,问题是在函数实现内部,任何泛型类型参数都是未指定的。依赖于未指定类型参数的条件类型大多被延迟,并且不进行求值。因此,编译器并没有真正看到任何具体的值可以分配给这样的类型;在microsoft/TypeScript#33912上,存在一个悬而未决的问题,要求找到某种方法来处理此问题,至少对于返回类型是这样。即使对于非条件类型,未指定的泛型也不太容易操作。

(更新TS4.3通过缩小泛型类型的值改善了以下情况,但仍然没有缩小泛型类型本身)一个主要障碍是,使用类型保护(例如format === 'hex')测试类型T的值并不能进行特定类型的控制流分析。在您的情况下,format根本没有缩小。有关详细信息,请参阅microsoft/TypeScript#13995

一般来说,解决此问题的一个障碍是编译器不会跟踪并集类型的多个表达式之间的相关性。在您的代码中,您想说formatvalue都是类型的并集,但它们不是独立的。由于每个并集有两个成员,编译器总是假设对[format, value]最多可以有四个可能的类型,对应于每个并集的每个成员,就好像它们是独立的一样。尽管您知道format的类型与value的类型相关,因此[format, value]只存在两种可能的类型,但编译器只是没有注意到这些事情。我在microsoft/TypeScript#30851上遇到了一个问题,希望对此提供一些支持,但编译器并不是按意愿运行的。


那么你能做什么呢?在general的情况下,您所能做的就是使用类型断言或等效的东西来告诉编译器它无法验证的东西。例如,您可以创建一个断言函数,该函数在运行时不执行任何操作,但告诉编译器将其参数的类型缩小为某个指定类型,如以下所示:

function compileTimeAssert<T>(x: any): asserts x is T {}

然后在您的实现中,您可以使用它:

if (format === 'hex') {
compileTimeAssert<string>(value); // I'm telling the compiler this
console.log(format, value.toUpperCase());
} else {
compileTimeAssert<ColorRgb>(value); // I'm telling the compiler this
console.log(format, value.r);
}

这样的断言只有在您创建它们时才是类型安全的;确保类型安全的责任已经从编译器转移到了您身上,所以要小心。


在您给出的特定示例中,我倾向于将Color从条件类型更改为对象属性查找,如下所示:

interface ColorMap {
hex: string;
rgb: ColorRgb;
}
type Color<T extends keyof ColorMap> = ColorMap[T];

没有必要这样做,但编译器更容易推理。除非有一个令人信服的理由让你能够编写Color<Date>并将其评估为never,否则我会这样做。

然后,我建议把processColor()看作一个函数,它的rest参数是元组类型。所以它不是(a: A, b: B)=>void,而是(...args: [A, B])=>void。您的约束对应于将rest参数类型设为元组的并集。你可以让编译器计算这种类型:

type ProcessColorParams =
{ [K in keyof ColorMap]: [format: K, value: Color<K>] }[keyof ColorMap];
// type ProcessColorParams = 
//   [format: "hex", value: string] | [format: "rgb", value: ColorRgb]

您可以看到,ProcessColorParams是一个并集,其中第一个元素是"hex",第二个元素是string,或者其中第一个元件是"rgb",而第二个元件是ColorRgb。由于第一个元素是字符串文字类型,所以这个并集是一个有区别的并集,在这里你可以检查第一个元素,编译器会自动缩小剩余元素的范围。

所以processColor可以变成tihs:

function processColor(...args: ProcessColorParams) {
if (args[0] === 'hex') {
console.log(args[0], args[1].toUpperCase());
} else {
console.log(args[0], args[1].r);
}
}

现在,约束是从调用端和实现中理解的强制执行的。是的,args[0]args[1]formatvalue更丑,但如果你把一个有区别的并集分解成单独的变量,你会遇到我上面提到的相关性问题。如果你想这样做,在歧视工会后再做:

function processColor(...args: ProcessColorParams) {
if (args[0] === 'hex') {
const [format, value] = args;
console.log(format, value.toUpperCase());
} else {
const [format, value] = args;
console.log(format, value.r);
}
}

到代码的游乐场链接

最新更新