14.反方向类型推导:用好上下文相关类型

10/9/2023

TypeScript 拥有非常强大的类型推导能力,不仅会在你声明一个变量时自动推导其类型,也会基于函数内部逻辑自动推导其返回值类型,还会在你使用 typeof 、instanceof 等工具时自动地收窄类型(可辨识联合类型)等等。这些类型推导其实有一个共同点:它们的推导依赖开发者的输入,比如变量声明、函数逻辑、类型保护都需要开发者的输入。实际上, TypeScript 中还存在着另一种类型推导,它默默无闻却又无处不在,它就是这一节的主角:上下文类型(Contextual Typing)

这一节的内容比较短,因为上下文类型并不是一个多复杂、多庞大的概念(不涉及实现源码的情况下),但在实际开发中,我们经常会受益于上下文类型的推导能力,只不过你可能不知道背后是它得作用。学完这一节,以后感受到上下文类型存在时,你就可以在心里默默地说一句:“谢谢你,上下文类型”。

本节代码见:Contextual Typings (opens new window)

# 无处不在的上下文类型

首先举一个最常见的例子:

window.onerror = (event, source, line, col, err) => {};
1

在这个例子里,虽然我们并没有为 onerror 的各个参数声明类型,但是它们也已经获得了正确的类型。

当然你肯定能猜到,这是因为 onerror 的类型声明已经内置了:

interface Handler {
  // 简化
  onerror: OnErrorEventHandlerNonNull;
}

interface OnErrorEventHandlerNonNull {
    (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error): any;
}
1
2
3
4
5
6
7
8

我们自己实现一个函数签名,其实也是一样的效果:

type CustomHandler = (name: string, age: number) => boolean;

// 也推导出了参数类型
const handler: CustomHandler = (arg1, arg2) => true;
1
2
3
4

除了参数类型,返回值类型同样会纳入管控:

declare const struct: {
  handler: CustomHandler;
};
// 不能将类型“void”分配给类型“boolean”。
struct.handler = (name, age) => {};
1
2
3
4
5

当然,不仅是箭头函数,函数表达式也是一样的效果,这里就不做展开了。

在这里,参数的类型基于其上下文类型中的参数类型位置来进行匹配,arg1 对应到 name ,所以是 string 类型,arg2 对应到 age,所以是 number 类型。这就是上下文类型的核心理念:基于位置的类型推导。同时,相对于我们上面提到的基于开发者输入进行的类型推导,上下文类型更像是反方向的类型推导,也就是基于已定义的类型来规范开发者的使用

在上下文类型中,我们实现的表达式可以只使用更少的参数,而不能使用更多,这还是因为上下文类型基于位置的匹配,一旦参数个数超过定义的数量,那就没法进行匹配了。

// 正常
window.onerror = (event) => {};
// 报错
window.onerror = (event, source, line, col, err, extra) => {};
1
2
3
4

上下文类型也可以进行”嵌套“情况下的类型推导,如以下这个例子:

declare let func: (raw: number) => (input: string) => any;

// raw → number
func = (raw) => {
  // input → string
  return (input) => {};
};
1
2
3
4
5
6
7

在某些情况下,上下文类型的推导能力也会失效,比如这里我们使用一个由函数类型组成的联合类型:

class Foo {
  foo!: number;
}

class Bar extends Foo {
  bar!: number;
}

let f1: { (input: Foo): void } | { (input: Bar): void };
// 参数“input”隐式具有“any”类型。
f1 = (input) => {}; // y :any
1
2
3
4
5
6
7
8
9
10
11

我们预期的结果是 input 被推导为 Foo | Bar 类型,也就是所有符合结构的函数类型的参数,但却失败了。这是因为 TypeScript 中的上下文类型目前暂时不支持这一判断方式(而不是这不属于上下文类型的能力范畴)。

你可以直接使用一个联合类型参数的函数签名:

let f2: { (input: Foo | Bar): void };
// Foo | Bar
f2 = (input) => {}; // y :any
1
2
3

而如果联合类型中将这两个类型再嵌套一层,此时上下文类型反而正常了:

let f3:
  | { (raw: number): (input: Foo) => void }
  | { (raw: number): (input: Bar) => void };

// raw → number
f3 = (raw) => {
  // input → Bar
  return (input) => {};
};
1
2
3
4
5
6
7
8
9

这里被推导为 Bar 的原因,其实还和我们此前了解的协变、逆变有关。任何接收 Foo 类型参数的地方,都可以接收一个 Bar 类型参数,因此推导到 Bar 类型要更加安全。

# void 返回值类型下的特殊情况

我们前面说到,上下文类型同样会推导并约束函数的返回值类型,但存在这么个特殊的情况,当内置函数类型的返回值类型为 void 时:

type CustomHandler = (name: string, age: number) => void;

const handler1: CustomHandler = (name, age) => true;
const handler2: CustomHandler = (name, age) => 'linbudu';
const handler3: CustomHandler = (name, age) => null;
const handler4: CustomHandler = (name, age) => undefined;
1
2
3
4
5
6

你会发现这个时候,我们的函数实现返回值类型变成了五花八门的样子,而且还都不会报错?同样的,这也是一条世界底层的规则,上下文类型对于 void 返回值类型的函数,并不会真的要求它啥都不能返回。然而,虽然这些函数实现可以返回任意类型的值,但对于调用结果的类型,仍然是 void

const result1 = handler1('linbudu', 599); // void
const result2 = handler2('linbudu', 599); // void
const result3 = handler3('linbudu', 599); // void
const result4 = handler4('linbudu', 599); // void
1
2
3
4

看起来这是一种很奇怪的、错误的行为,但实际上,我们日常开发中的很多代码都需要这一“不正确的”行为才不会报错,比如以下这个例子:

const arr: number[] = [];
const list: number[] = [1, 2, 3];

list.forEach((item) => arr.push(item));
1
2
3
4

这是我们常用的简写方式,然而,push 方法的返回值是一个 number 类型(push 后数组的长度),而 forEach 的上下文类型声明中要求返回值是 void 类型。如果此时 void 类型真的不允许任何返回值,那这里我们就需要多套一个代码块才能确保类型符合了。

但这真的是有必要的吗?对于一个 void 类型的函数,我们真的会去消费它的返回值吗?既然不会,那么它想返回什么,全凭它乐意就好了。我们还可以用另一种方式来描述这个概念:你可以将返回值非 void 类型的函数(() => list.push())作为返回值类型为 void 类型(arr.forEach)的函数类型参数

# 总结与预告

在这一节里,我们学习了上下文类型这“另一个方向”的类型推导,了解了它是基于位置进行类型匹配的,以及上下文类型中 void 类型返回值的特殊情况。

这一节比较轻松对吧?那在下一节,我们会学习一个稍微复杂点的概念:函数类型兼容性比较,以及其中的协变逆变概念。我们在前面类型层级一节中,并没有提及函数类型地比较,这也是因为其中的概念相对复杂,需要更多的前置知识与更多的消化过程,因此我单独准备了一节内容。

# 扩展阅读

# 将更少参数的函数赋值给具有更多参数的函数类型

在上面的例子中,我们看到了这么一段代码:

const arr: number[] = [];
const list: number[] = [1, 2, 3];

list.forEach((item) => arr.push(item));
1
2
3
4

在 forEach 的函数中,我们会消费 list 的每一个成员。但我们有时也会遇到并不实际消费数组成员的情况:

list.forEach(() => arr.push(otherFactory));
1

这个时候,我们实际上就是在将更少参数的函数赋值给具有更多参数的函数类型

再看一个更明显的例子:

function handler(arg: string) {
  console.log(arg);
}

function useHandler(callback: (arg1: string, arg2: number) => void) {
  callback('linbudu', 599);
}

useHandler(handler);
1
2
3
4
5
6
7
8
9

handler 函数的类型签名很明显与 useHandler 函数的 callback 类型签名并不一致,但这里却没有报错。从实用意义的角度来看,如果我们需要类型签名完全一致,那么就需要为 handler 再声明一个额外的对应到 arg2 的参数,然而我们的 handler 代码里实际上并没有去消费第二个参数。这实际上在 JavaScript 中也是我们经常使用的方式:即使用更少入参的函数来作为一个预期更多入参函数参数的实现

Last Updated: 10/9/2023, 5:43:25 PM