14-反方向类型推导:用好上下文相关类型
课程
1
开篇:用正确的方式学习 TypeScript
已学完
学习时长: 7分24秒
2
工欲善其事:打造最舒适的 TypeScript 开发环境
学习时长: 19分14秒
3
进入类型的世界:理解原始类型与对象类型
学习时长: 37分40秒
4
掌握字面量类型与枚举,让你的类型再精确一些
学习时长: 24分53秒
5
函数与 Class 中的类型:详解函数重载与面向对象
学习时长: 50分40秒
6
探秘内置类型:any、unknown、never 与类型断言
学习时长: 34分58秒
7
类型编程好帮手:TypeScript 类型工具(上)
学习时长: 33分34秒
8
类型编程好帮手:TypeScript 类型工具(下)
学习时长: 40分52秒
9
类型编程基石:TypeScript 中无处不在的泛型
学习时长: 43分46秒
10
结构化类型系统:类型兼容性判断的幕后
学习时长: 20分48秒
11
类型系统层级:从 Top Type 到 Bottom Type
学习时长: 45分16秒
12
类型里的逻辑运算:条件类型与 infer
学习时长: 52分44秒
13
内置工具类型基础:别再妖魔化工具类型了!
学习时长: 44分47秒
14
反方向类型推导:用好上下文相关类型
学习时长: 18分37秒
15
函数类型:协变与逆变的比较
学习时长: 21分38秒
16
了解类型编程与类型体操的意义,找到平衡点
学习时长: 4分27秒
17
内置工具类型进阶:类型编程进阶
学习时长: 83分3秒
18
基础类型新成员:模板字符串类型入门
学习时长: 32分58秒
19
类型编程新范式:模板字符串工具类型进阶
学习时长: 69分27秒
20
工程层面的类型能力:类型声明、类型指令与命名空间
学习时长: 53分23秒
21
在 React 中愉快地使用 TypeScript:内置类型与泛型坑位
学习时长: 78分9秒
22
让 ESLint 来约束你的 TypeScript 代码:配置与规则集介绍
学习时长: 57分13秒
23
全链路 TypeScript 工具库,找到适合你的工具
学习时长: 25分13秒
24
说说 TypeScript 和 ECMAScript 之间那些事儿
学习时长: 29分14秒
25
装饰器与反射元数据:了解装饰器基本原理与应用
学习时长: 81分53秒
26
控制反转与依赖注入:基于装饰器的依赖注入实现
学习时长: 69分6秒
27
TSConfig 全解(上):构建相关配置
学习时长: 55分33秒
28
TSConfig 全解(下):检查相关、工程相关配置
学习时长: 76分19秒
29
基于 Prisma + NestJs 的 Node API :前置知识储备
学习时长: 44分50秒
30
基于 Prisma + NestJs 的 Node API :项目开发与基于 Heroku 部署
学习时长: 46分40秒
31
玩转 TypeScript AST:AST Checker 与 CodeMod
学习时长: 85分18秒
32
感谢相伴:是结束,也是开始
学习时长: 3分21秒
33
漫谈篇:面试中的 TypeScript
学习时长: 4分25秒
juejin_logo copyCreated with Sketch.

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

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

本节代码见:Contextual Typings

无处不在的上下文类型

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

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

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

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

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

interface OnErrorEventHandlerNonNull {
    (event: Event | string, source?: string, lineno?: number, colno?: number, error?: Error): any;
}

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

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

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

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

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

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

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

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

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

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

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

// raw → number
func = (raw) => {
  // input → string
  return (input) => {};
};

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

class Foo {
  foo!: number;
}

class Bar extends Foo {
  bar!: number;
}

let f1: { (input: Foo): void } | { (input: Bar): void };
// 参数“input”隐式具有“any”类型。
f1 = (input) => {};

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

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

let f2: { (input: Foo | Bar): void };
// Foo | Bar
f2 = (input) => {};

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

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

// raw → number
f3 = (raw) => {
  // input → Bar
  return (input) => {};
};

这里被推导为 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;

你会发现这个时候,我们的函数实现返回值类型变成了五花八门的样子,而且还都不会报错?同样的,这也是一条世界底层的规则,上下文类型对于 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

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

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

list.forEach((item) => arr.push(item));

这是我们常用的简写方式,然而,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));

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

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

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

再看一个更明显的例子:

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

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

useHandler(handler);

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

留言
Ctrl + Enter
全部评论(15)
愤怒的小貔貅的头像
删除
有些不太理解上下文类型和函数类型它们的区别是什么?
点赞
回复
前端摸鱼儿的头像
删除
let f2: { (input: Foo | Bar): void };
// Foo | Bar
f2 = (input) => {}; // y :any
这个y:any 应该是 y: Foo | Bar 吧
点赞
2
删除
(作者)
我忘记这个 y 是想描述啥了 = =,和这里似乎没有关系,入参类型在上面标出来了,先去掉了
点赞
回复
删除
好的
我忘记这个 y 是想描述啥了 = =,和这里似乎没有关系,入参类型在上面标出来了,先去掉了
点赞
回复
undefinedNullObject的头像
删除
任何接收 Foo 类型参数的地方,都可以接收一个 Bar 类型参数,因此推导到 Bar 类型要更加安全。
-- 不是有bar,不是任何接收 Bar 类型参数的地方,都可以接收一个 Foo 类型参数吗?有点蒙
点赞
3
删除
(作者)
Bar 是子类型,Foo 是父类型,参考协变与逆变一节中的介绍
点赞
回复
删除
哦哦,懂了
Bar 是子类型,Foo 是父类型,参考协变与逆变一节中的介绍
点赞
回复
删除
(其实还和我们**此前**了解的协变、逆变有关。)当时有点懵, 我以为看了个寂寞(一点儿印象都没)。。 原来在后面[捂脸]
Bar 是子类型,Foo 是父类型,参考协变与逆变一节中的介绍
点赞
回复
前端摸鱼儿的头像
删除
let f3:

| { (raw: number): (input: Foo) => void }

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

第二行开头为什么有个竖线
收起
1
1
删除
(作者)
格式化工具为了美观使用的,也是联合类型支持的用法
2
回复
癡癡的等你歸的头像
删除
听我说谢谢你,上下文类型!因为有你,温暖了四季。
点赞
回复
micstone的头像
删除
前端工程师
arr.forEach((item) => list.push(item));


应该是:
list.forEach((item) => arr.push(item));
吧?
2
3
删除
(作者)
已修正
点赞
回复
删除
其实有两个地方,只修改了一处。
已修正
1
回复
删除
(作者)
回复
现在都改了~
其实有两个地方,只修改了一处。
点赞
回复
The action has been successful