返回首页

TypeScript 里提取函数参数类型

用 infer 提取函数的参数 / 返回值类型,以及它在 union 上的限制。

发布 2019年3月26日 标签 #typescript #type-system

~/posts/typescript-function-argument-types $ cat post.md

/ 语言 EN / 中文
/ 主题 / /

起因

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);
    }
}

两个问题:

  1. TS 不把 FunctionArguments<VALUES[T]> 当作”一定是数组”来处理——即使根据定义,VALUES[T] 的参数化身在任何情况下用类型表示都应该是数组。
  2. 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 的路子。

返回首页