关于 typescript 泛型中返回值类型约束的问题
最近遇到这么一个需求。
定义一个函数接口,要求其返回值类型是 type A 的任意超集。
于是我按直觉写下了:
type A = { a: string }
type FuncA = <T extends A>() => T
const f: FuncA = () => {
return { a: "ok" }
}
人来看非常简单知道是什么意思,就是返回值包含所有 a 的属性,其他属性全是可有可无的。
这段代码扔给 GPT,它也看不出什么毛病。但事实上,在 return 时报了一个错:
Type '() => A' is not assignable to type 'FuncA'.
Type 'A' is not assignable to type 'T'.
'A' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'A'.ts(2322)
这个报错非常的不 helpful。因为平时, typescript 可以根据返回值推测出具体函数标注。比如
function foo(){
return "1"
} // 自动推断出函数的具体签名为 () => string
那为什么上面的报错例子,不能做这样的推断呢?
type A = { a: string }
type FuncA = <T extends A>() => T
const f: FuncA = () => {
return { a: "ok" }
}
/* 推断出具体的签名类似于
() => {
a: string;
[name: string]: any
}
*/
也就是说,a 是必选属性,其他属性全是 optional。
(先不讨论 Object 的 key 可以是 Symbol,只是为了看起来好理解,我只写了 string。要写全这里又要多写一个类型推断。)
当然这里又引发了另一个问题:你为什么不直接把 type A 定义附加任意可选属性?
好问题,这是一个正常的解决 TS2322 问题的思路。但是我就是想知道为什么泛型推断不能直接做这个……
我查了很多资料,没有人完美解释这个问题。但有一个相似的问题:如何让参数和返回值持有相同的泛型类型?
在 typescript 的 github issue 里有详细的案例说明,务必看看,很好懂,说是故意这么设计的。这里我将理由简短概括如下:
如果 f 是上有一个额外的属性 prop,编译器如果推导出了返回值类型成 typeof f。之后你调用 f.prop,静态编译不会报错,但实际上有一个 runtime error,因为你的真实的返回值只是一个
()=>{}
,没有prop 属性。
但个人觉得这里静态编译应该报错,并不是一个 runtime 错误。前面说了,typescript 可以对返回值进行静态的类型的检查。以上面 issue 为例,理想的报错设计是长这样:
type A = () => void;
type B = () => void;
// 类型签名为 <T extends A | B>(value: T) => T 的实现
function f1<T extends A | B>(value: T): T {
return () => {}; // 推断出 T 此时是 typeof ()=>{},也就是 ()=>{}
}
let f: any= ()=>{}
f.prop = "haha"
f1(f) // 这里传参报错,因为 typeof f 和 typeof ()=>{} 不一致。本质上就是 ts2322 描述的问题,但不应该在上面报错
当然上面的例子返回值类型已经定了是 typeof ()=>{}
,返回值再标注 T 显得十分多此一举。但是 f1 对只是对这个函数签名的一种实现。完全可以实现对这个函数签名有不同的实现,返回不同的 subtype。
什么是 subtype?T extends A,T 就是 A 的 subtype
这又引发了另一个问题:这和函数重载有什么区别?
当然有区别啊,最大的区别就是我能定义一个统一的函数接口,只要返回值满足最基本的约束 A
。但可以是返回不同的 subtype,实现也分开写到不同的文件里,类似于 oop 语言中返回所有某基类的派生类。这才是完全体。
但现在的 typescript 完全做不到这一点,返回值只能是一个非常具体的 type,要么就抛出一个毫无说服力的 ts2322 错误。
如果要解决开头的问题,大概是以下三个思路:
- 定义 A 时,把所有可能要用到的属性都写到可选属性里,或直接
[name: string]: any
。 - 考虑业务场景,其他未知属性不留下会影响到什么吗。99% 的场景是没有必要的,也就是说这个需求就是没意义的。剩下的 1% 我没有遇到/想到。
- 根据输入参数的 T 写一个类型推导,手动将返回的类型设置为 a 的具体扩展类型。类似这样
type Extend<T extends object> = {
[name: string]: any
} & {
[K in keyof T]: T[K]
}
type A = {a: string}
type FuncA = () => Extend<A>
const f: FuncA = () => {
return { a: "ok", b:"extra"}
}
f().a // a is string
f().b // b is any
总之,在目前的 typescript 中,返回值类型不能是泛型。
当然这样也失去了扩展的类型检查,等于是用了函数的签名来检查的,和返回值的类型一点关系也没有。
现在 typescript 的静态检查器其实已经做了一些运行时的功能,比如条件语句判断以排除属性。但是,这些像运行时一样的检查只在静态类型不明确时才起作用。就这个 if,我已经遇到了好几次无法判断的 bug ,清空缓存并重启才恢复。
说回第二点,既然你允许传了任意值,也就说明在你这个库中,你也不知道其他附加值具体是拿来干什么的,大多无非遍历一下再过滤一下。如果是静态类型检查器来遍历,诶诶扩展属性怎么全是 any。最终还得用 JS 的运行时来做这个事情……所以有拿来做什么的话早就在 A 里增加 optional 属性了。这也是为什么说 99% 的场景这个需求其实不存在。
还有一个更重要的原因,那就是,ts 的类型体操,实在太他妈难写了。
可能没用的参考: