SANSUI'S BLOG

系统外观
分类标签
RSS
Sansui 2023
All rights reserved
人活着就是为了卡卡西

关于 typescript 泛型中返回值类型约束的问题

11 月 12 日, 2023

最近遇到这么一个需求。

定义一个函数接口,要求其返回值类型是 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 的类型体操,实在太他妈难写了。


可能没用的参考:

更新于 2023-11-12 06:26