众所周知,在 TypeScript 中,类型检查是非常重要的一环。为了类型检查,TypeScript 本身带来了一定的额外负担。而在我们实际的日常的开发中,我们会发现这个负担并不算大。而为了让我们在使用中有更好的体验, TypeScript 的类型推导规则非常复杂。而其中有一个非常常见,非常重要,但又很容易被忽略的概念: 上下文相关类型 (Contextual Typing)。
什么是上下文相关类型
首先,让我们看一下什么是上下文相关类型。
在 TypeScript 中,除类型标记之外,我们有很多途径可以推导类型,例如对于 let x = 42
, 我们可以根据 x
的初始化表达式字面量 42
推导出类型 number
,即通过值推导类型。在一些情况下,TypeScript 提供了另一种方向的类型推导,即根据表达式所处的上下文来推导表达式的类型,这种推导方式所推导出来的类型被称为上下文相关类型。
下面举一个常见但经常被忽略的例子:
const s: (v: string) => void = v => {}; // v is string
当我们将一个并没有标记参数类型(或返回值)的函数作为初始化表达式赋值给一个标记了类型的变量声明时,我们会发现函数的参数根据上下文中隐含的类型(例中为 s
的类型标记 (v: string) => void
)被推导为了相应的类型 (例中为 string
)。
相信大家日常都有遇到类型的现象,但由于相关的资料较少,并且规则复杂(多变),常常被当做一个黑盒,或者直接被忽略了,下面就由笔者来为大家揭开它一角神秘的面纱。
本文结合了 TypeScript Spec(已过期),TypeScript 本身的实现与TypeScript 本身的测试用例(截止 3.6版本),以及一些 TypeScript 提交中包含的信息,结合示例,为大家介绍与梳理上下文相关类型。
上下文相关类型的推导规则
上文中提到,TypeScript 的类型推导规则非常复杂,尤其涉及泛型,类型参数推导,高阶类型与上下文相关类型等复杂类型的部分。由于篇幅以及阅读体验和笔者水平的限制,本文从实际工程应用出发,不会涉及太多 TypeScript 自身实现与编程语言理论的知识,大多以文字以及用例的形式来展现。
包含在变量声明式语句中
当前表达式包含在类型声明,参数声明,属性声明,属性解构等 变量声明式语句 (VariableLikeDeclaration) 中时,可以从声明中进行上下文相关类型推导, 除上文展示的例子外,还支持如下形式:
declare const ff: (v: string) => void;
// IIFE
// vv is string
((v = vv => {}) => {})(ff);
// Function Declaration
// vv is string
function f (a: (v: string) => void = vv => {}) { }
// Binding Element
// vv is string
const s1: (a: {foo: (v: string) => void}) => void = ({foo = vv => {}}) => {}
// PropertyDeclaration
// vv is string
class C1 {
foo: (v: string) => void = vv => {}
}
如例中所示,包含在参数声明中(默认参数)时,会先选取包含参数声明的函数的上下文相关签名,然后根据参数的位置进行匹配和推导; 而对于属性解构中的表达式(默认值),会获取解构属性所解构的值的上下文相关类型,然后根据解构的名称获取对应的类型;变量声明和属性声明的初始化表达式会根据变量声明的类型标记推导上下文相关类型。
多级上下文相关类型的推导规则
由上例中不难看出,上下文相关类型的推导由表达式本身向父级逆向进行。以 s1
中的 vv
为例:
- 对
vv => {}
取上下文相关类型,由于其包含在属性解构中,则取属性解构所解构的值的上下文相关类型,即其对应的第一个参数({foo = vv => {}}) => {}
。 - 表达式
({foo = vv => {}}) => {}
的上下文相关类型又由于其包含在s1
的变量声明中,则被推导为s1
所标记的类型(v: {foo: (v: string) => void}) => void
。 - 由此类推,获得
vv
的类型为string
。
上下文相关签名的推导规则
对函数提取上下文相关签名时,有一系列的规则。例如:
// vv is string
const ss1: (((v: (v: string) => void) => void) | ((v: (v: string) => void) => number)) = (v = vv => {} ) => {}
// vv is any
const ss2: (((v: (v: string) => void) => void) | ((v: (v: string) => void, a: any) => number)) = (v = vv => {} ) => {}
interface Callable {
(v: (v: string) => void): void;
(): void
}
const ss3: Callable = (v = vv => {}) => { } // vv is any
如前例及例中所示,如下条件情况时,才可推导出上下文相关签名:
- 一个类型有且仅有一个调用签名。
- 一个类型是联合类型(Union),这个联合类型的每一个子项都有且仅有一个调用签名,并且所有子项的签名在忽略返回值类型的情况下类型匹配。
包含在函数 Return 中
当前表达式包含在箭头函数以及函数 return
语句中时,会从获取包含当前表达式的函数的上下文相关返回值类型。例如:
const a1: () => (v: string) => void = () => vv => {} // vv is string
const a2: () => (v: string) => void = () => { return vv => {} } // vv is string
函数的上下文相关返回值类型的推导规则
推导函数的上下文相关返回值类型时,会按照如下规则推导:
- 函数有返回值类型标记, 则推导为返回值类型标记代表的类型。
- 函数是
constructor
,则推导为包含constructor
的class
的类型。 - 函数是
getter
并且对应的setter
有参数,则推导为setter
参数的类型标记。 - 函数是函数表达式,箭头函数或对象字面量的方法声明,则获取上下文相关签名并尝试获取返回值。
包含在 Yield 表达式中
当前表达式包含在 yield
中时,会选取包含当前表达式的 Generator
函数的上下文相关返回值类型后推导。例如:
function * g1 (): Generator<(v: string) => void> {
yield vv => {}; // vv is string
}
包含在 Await 表达式中
当前表达式包含在 await
中时,会获取整个 Await 表达式的上下文相关类型进行推导。例如:
async function async1() {
const v1: (v: string) => void = await (vv => {}) // vv is string
const v2: (v: string) => void = await Promise.resolve(vv => {}) // vv is string
}
包含在函数调用中
当前表达式包含在函数调用或 New 表达式的参数中时,会从获取调用对象的调用签名,后根据当前表达式所在位置对应的参数的类型进行类型推导。例如:
declare const s2: (a: (v: string) => void) => void
s2(vv => {}) // vv is string
class C2 { constructor (a: (v: string) => void) {}}
new C2(vv => {}) // vv is string
包含在类型断言中
当前表达式包含在类型断言或 as
中时,如果不是 const
断言,则推导为断言标记的类型。例如:
const aa1 = (vv => {}) as (v: string) => void // vv is string
const aa2 = <(v: string) => void>(vv => {}) // vv is string
包含在二元表达式中
当前表达式包含在二元表达式(BinaryExpression) 中时,根据不同的操作符会有不同的推导规则。例如:
declare const s: (v: string) => void
declare const ff1: Record<string, (v: string) => void>
declare const ff2: { name: (v: string) => void }
let ff3: (v: string) => void
ff1.name = vv => {} // vv is string
ff2.name = vv => {} // vv is string
ff3 = vv => {} // vv is string
// noImplicitThis
const ff4 = {
s,
foo () {
this.s = vv => {} // vv is string
}
}
const ff5: (v: string) => void = (s || (vv => {})) // vv is string
const ff6 = (s || (vv => {})) // vv is string
const ff7: (v: string) => void = (void 0, (vv => {})) // vv is string
const ff8 = s || (vv => {}) // vv is string
如例中所示,对于形如 left op right
的二元表达式:
- 当二元表达式为赋值表达式,即操作符为
=
,且当前表达式位于 right 分支时,会尝试获取left
分支的类型,并且: - 当
left
分支为标识符或属性访问时,会获取left
声明处对应的类型。 - 其中, 当
noImplicitThis
选项开启,且二元表达式为处于对象字面量的方法声明中,同时left
分支为this.xxx
形式的属性访问时,this
的类型会被推导为对象字面量。 - 当二元表达式的操作符为
&&
或,
时,会获取整个二元表达式的上下文相关类型。 - 当二元表达式的操作符为
||
或即将支持的??
中时,会尝试获取整个二元表达式的上下文相关类型,并且当当前表达式在right
分支中,并且二元表达式没有上下文相关类型时,会尝试获取left
分支的类型并进行匹配。
包含在属性赋值中
当前表达式包含在属性赋值(PropertyAssignment)或快捷属性赋值(ShorthandPropertyAssignment)中时,会根据属性赋值的父级,即对象字面量的上下文相关类型进行,和所赋值的属性的名字或索引类型签名进行类型推导。 例如:
const NI1: { [key: number]: (v: string) => void } = {
[1]: vv => {}, // v is string;
}
const NS2: { [key: string]: (v: string) => void } = {
a: vv => {} // v is string
}
const NS3: { x: (v: string ) => void} = {
x: vv => {} // v is string
}
包含在数组字面量中
当前表达式包含在数组字面量(ArrayLiteralExpression)中时,会根据数组字面量的上下文相关类型进行推导,对于元组类型 (Tuple)还会根据当前表达式的位置进行匹配。例如:
const arr1: ((v: string) => void)[] = [v => {}] // v is string
// v1 is string, v2 is number
const arr2: [(v: string) => void, (v: number) => void] = [v1 => {}, v2 => {}]
包含在判断表达式中
当前表达式包含在形如 cond ? true : false
的判断表达式中时,如果当前表达式包含在 left
或 right
分支中时,会根据整个判断表达式的上下文相关类型进行推导。例如:
// v1 and v2 are string
const a: (v: string) => void = Math.random() ? (v1 => {}) : ((v2 => {}));
包含在标签模板表达式中
当前表达式包含在标签模板(TaggedTemplate)中时,由于标签模板是一种特殊的函数调用,因此同样会对标签模板的标签函数根据函数调用的规则进行推导。例如:
declare function tag1(
str: TemplateStringsArray,
a: (v: string) => void,
b: (v: string) => void
): void;
tag1`a${v1 => {}}, ${v2 => {}}b`; // v1 and v2 are string
包含在括号表达式中
当前表达式包含在括号中时,会简单的根据括号整体进行上下文类型推导。例如:
const s: (v: string) => void = (vv => {}) // vv is string
包含在 Jsx 表达式中
TypeScript 本身支持了 Jsx 语法即 Tsx,以及对应的类型推导,在 Tsx 中,同样存在上下文相关类型推导。当前表达式包含在 Jsx 表达式 (JsxExpression)中时,会根据 Jsx 表达式的环境判断上下文相关类型。例如:
const tsx = (
<App p={v => {} /* v is string*/ }>
{v => {} /* v is string */}
</App>
)
- 当 Jsx 表达式包含在 Jsx 属性 (JsxAttribute)中时,会获取 Jsx 表达式的上下文相关类型,即获取被包含的 Jsx 属性的上下文相关类型进行推导。
- 当 Jsx 表达式包含在 Jsx 元素 (JsxElement) 中时,会获取包含 Jsx 表达式的 Jsx Element 对应的
Props
类型,并且获取children
的类型进行推导。
结语
可以看到,上下文相关类型作为 TypeScript 中的重要组成部分,有相当的复杂度,并且也非常贴近日常使用。本文仅是抛砖引玉,期望能够帮助大家了解自己使用的工具与写的代码。
受笔者自身水平限制,若各位在阅读中发现问题与错误,请不吝指正。
其他
参考资料
- TypeScript Handbook
- TypeScript Spec
- Higher order function type inference
感谢
感谢各位朋友在本文完成过程中给予的帮助,以下为字典序。
- Haotian Wu
- Jiahao Guo
- Jian Yang
- Jinxiang Zhao
- Zheng JIang
- Zijian Zuo
- fightingcat