上次更新于

NoInfer 拯救了发愁好几个通宵的我

不推导工具类使我成功推导范型推导的推导规则


我在计数小工具应用的文章中提到,我弃用了第三方的 UI 库,除了因为那些库各有各的问题,还因为这样实现设计更加顺畅。但其中也有过特别苦恼的时候,而 React 的 ref 就是导火索。

在 React 中,一些类似聚焦的操作需要先拿到具体节点的引用,也就是 ref,然后调用相应的方法,比如 ref.current.focus()。所以在我编写自己的 UI 组件时,需要把引用传递到更底层的组件以使得相关的接口能被使用。但由于 ref 不是一个通常的属性,而是被 React 特殊处理的(可喜的是最新的 React 19 已经把它变为普通的属性了),对于函数组件来说,就需要使用 forwardRef 来传递引用。由于 forwardRef 的使用逻辑相对复杂,我自然就想到了写一个 HOC(Higher Order Component,是一种组件逻辑复用的设计模式,通过编写一个处理函数,把要复用的逻辑包裹到参数传入的已有组件上,形成新的组件再返回出去)来复用它们,而且还可以顺便实现一些常用的 HOC 功能,比如为某个组件提供默认样式或是根据配置参数自动地生成变体组件。

但此时问题就出现了,Typescript 无法正确地给这个 HOC 包裹完的组件的属性提供类型提示。而这也暴露了我对 Typescript 经验的不足,花费了好久也没研究明白问题出在了哪里。当然类型提示有错其实并不影响使用,甚至这个 HOC 本身也就是一个语法糖,并不是必须的,更何况我还有别的绕过这个问题的办法。所以说我纠结的主要目的还是搞清楚这个问题的原因,以获得对 Typescript 更深的理解。功夫不负有心人,我也确实找到了。

为了便于理解,这里使用一个简化的场景来说明问题:

function f<A, B extends Pick<A, "key1" | "key2">>(
    a: A,
    b: B
): B {...}

可以看到在这个范型函数中,B 是一个基于 A 的类型,那理论上 Typescript 可以通过获取参数 a 的实际类型从而知道参数 b 和返回值的实际类型。那么在编码时,当我输入完第一个参数 a 之后,编辑器就能给我第二个参数 b 的类型提示,到这里为止是对的,但到了返回值的时候,就有可能是错的了。明明用了同一个范型类型 B,为什么类型提示会不一样呢?从正确的写法反推就能理解了:

function f<A, B extends Pick<A, "key1" | "key2">>(
    a: A,
    b: NoInfer(B)
): B {...}

这里唯一的区别是参数 b 的类型套上了一个 Typescript 定义的工具类型 NoInfer,这也就是我前面提到的利器,它的作用是告知 Typescript 不要从中推导类型。放到这个场景中就是说,让 Typescript 不要根据参数 b 的类型来推导 B 这个范型的实际类型。也就是说在没有 NoInfer 的情况下,虽然在输入参数 b 的时候,Typescript 确实从参数 a 的类型推导出了 B,也就是参数 b 该有的类型,参数 b 的类型提示也是正确的;但在决定返回值的类型时,由于参数 b 已经写好了,Typescript 就再一次地进行了推导,把参数 b 的实际类型当作了 B,也就是返回值的类型,然而 b 的值有可能只是 B 的子集,比如这个例子:

type A = {
    key1?: string;
    key2?: number;
    ...
};
const a: A = ...;
const ret = f(a, { key1: "hello" });

这里正确的返回值的类型应该是 Pick<A, "key1" | "key2">,也就是 { key1?: string; key2?: string },但实际上,ret 的类型将会是 { key1: string }

除开解决问题本身的喜悦,这对我来说还是一次有趣的推理体验,是一次针对 Typescript 类型推导机制的从现象到本质的反向推导。