TypeScript 里提取函数参数类型
用 infer 提取函数的参数 / 返回值类型,以及它在 union 上的限制。
~/posts/typescript-function-argument-types $ cat post.md
起因
Bark Core 需要写一个钩子机制。结构类似 Event:消费者把回调注册到一个类里;当软件运行到某些位置时,框架查表看 key 有没有被注册过,注册过就调对应回调。
TS 这边自然要把类型系统配到最好。熟悉 TS 的同学一定会想到 @types/node 里关于 Event 的那一套类型——对于 on(key: string, fn: ...) 这种签名已经有比较成熟的方案了。但这套方案在我们这里不太合适。先看一下 on 的官方实现长什么样,再讨论我们的需求。
EventEmitter 的 on
.d.ts 里的源码就不贴了。它本质上是写了很多个 on 函数的重载(overload),像 Java 那样。这种写法扩展性不好、也不太美观。我们换一种基于实际场景的写法。
需求是:有一个 key → value 的对照表。当 on 的第一个参数是 T extends KEYS 时,第二个参数应该是 Value[T]。Value[T] 是带某些参数、返回 void 的回调函数。
enum KEYS {
HELLO = "HELLO",
}
type VALUES = {
[KEYS.HELLO]: (a: string) => void;
};
on 函数本身就好写了:
class A {
public on<T extends KEYS>(key: T, callback: VALUES[T]) {
// ...add listener
}
}
代理调用
基于 Event 这套机制的实现通常是 JS 写的,加上类型也不算难。我们要的是代理调用——B 类里有一个 call,它接收 key 和参数,然后把参数转给 A 类的 call:
class B {
public call<T extends KEYS>(key: T, ...args: /* Arguments of VALUES[T] */) {
a.call(...args);
}
}
也就是说,需要一个能从 VALUES[T] 提取参数类型的工具类型——可以理解为 Arguments of VALUES[T]。
用 infer 提取
TypeScript 3.0 之后支持 infer 关键字——可以理解为一个占位符,把你想要的位置标出来。利用它写出参数类型提取器:
type FunctionArguments<T> = T extends (...args: infer U) => any ? U : never;
类似地,把 infer 放到返回值位置,就得到返回值类型提取器:
type FunctionReturn<T> = T extends (...args: any) => infer U ? U : never;
这是我最初的方案,效果不错。直到我撞上了一个 TS 行为(可能是 bug 也可能是实现限制,我用的是 TS 3.3.3333)。
撞墙
TS 在做 infer 时其实是按 union 来推导的:
class A {
public on<T extends KEYS>(key: T, callback: VALUES[T]) {
// ...
}
}
class B {
public call<T extends KEYS>(key: T, ...args: FunctionArguments<VALUES[T]>) {
a.call(...args);
}
}
两个问题:
- TS 不把
FunctionArguments<VALUES[T]>当作”一定是数组”来处理——即使根据定义,VALUES[T]的参数化身在任何情况下用类型表示都应该是数组。 - 当
VALUES有多个 key 时:
type VALUES = {
[KEYS.HELLO]: (a: string) => void;
[KEYS.WORLD]: () => void;
[KEYS.SOMETHING]: (b: string, c: number) => void;
};
理论上 FunctionArguments<VALUES[KEYS.WORLD]> 应该是 []。但实际上不管 T 取哪个 key,TS 都会推成 [string] | [] | [string, number]——把所有 key 对应的可能性 union 起来。这就没法用了。
反向思路:直接把参数当类型存
既然提取不靠谱,就反过来——直接把”参数列表”作为 VALUES 里的类型存:
type VALUES = {
[KEYS.HELLO]: [string];
[KEYS.WORLD]: [];
[KEYS.SOMETHING]: [string, number];
};
实测这是当时最顺手的方案。代价是丢失了返回值信息——如果需要保留返回值,要么用两套 VALUE 定义(一份参数、一份返回值),要么回到上面 infer 的路子。