- 文档
- 教程
【免费下载链接】typescript-book-chinese
TypeScript Deep Dive 中文版
类型推断(Type Inference)是 TypeScript 静态类型系统的核心机制之一:在绝大多数情况下,你无需显式书写类型注解,编译器会根据一些简单而一致的规则,自动"算出"变量、函数返回值和复杂结构的类型。本文以《深入理解 TypeScript》(typescript-book-chinese 仓库)中的 docs/typings/typeInference.md 为骨架,结合仓库内类型保护、类型兼容性、泛型与infer等姊妹章节,系统讲解类型推断的七大场景、类型"流动方向"的底层心智模型,以及noImplicitAny等编译器选项对推断行为的约束。读完本文,你将能够熟练地"预测"TypeScript 在何时会推断出什么类型、何时会报错,从而写出无需冗余注解、类型却依然安全的代码。
类型推断的本质:从初始化表达式推导类型
TypeScript 能根据一些简单的规则推断(检查)变量的类型,这些规则可以通过实践很快掌握。其中最重要的一条是:变量的类型,由定义(初始化表达式)推断。
let foo = 123; // foo 是 'number' let bar = 'hello'; // bar 是 'string' foo = bar; // Error: 不能将 'string' 赋值给 `number`这里的关键在于:foo的类型在声明的那一刻就被123这个字面量"锁定"为number,此后任何与number不兼容的赋值都会在编译期被拦截。
从信息流向的角度看,这是一个从右向左流动类型的示例:类型信息从等号右侧的初始化表达式流向左侧的变量声明。理解"流向"这个概念很重要,它是后续所有推断规则的心智模型基础——类型推断并不是随意的猜测,而是沿着 JavaScript 代码天然的赋值与求值结构,把类型信息从一个位置传递到另一个位置。
函数返回类型:从 return 语句向上推断
函数的返回类型能被return语句推断出来。例如下面的add函数,虽然没有任何显式返回类型注解,TypeScript 依然能推断出它返回一个数字:
function add(a: number, b: number) { return a + b; }由于a、b都是number,a + b的结果自然是number,因此add的返回类型被推断为number。
从流向的角度看,这是一个从底部流出类型的例子:类型信息从函数体底部的return语句向上"流出",汇聚为函数的签名。当你在其他地方调用add并试图把结果当作非number类型使用时,编译器会依据这个推断出的返回类型给出错误。
值得注意的是,返回类型推断在一般情况下是可靠的,但在某些特殊场景(如函数内部调用了隐式any的函数)下会产生"污染",这一点我们会在后文"小心使用返回值"一节专门讨论。
赋值驱动的推断:函数类型与回调参数
函数参数类型和返回值也能通过赋值来推断。看下面的例子:foo被注解为Adder类型,而Adder是一个(a: number, b: number) => number的函数类型,因此foo的参数a、b会被推断为number:
type Adder = (a: number, b: number) => number; let foo: Adder = (a, b) => a + b;这个事实可以用下面的代码来证明——TypeScript 会发出正如你期望的错误警告:
type Adder = (a: number, b: number) => number; let foo: Adder = (a, b) => { a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型 return a + b; };这里类型信息从左侧的类型注解流向右侧的函数实现,是一个从左向右流动类型的示例。函数实现中参数的"空位"(没有注解的a、b)会被左侧声明的函数类型签名填充。
更进一步,如果你创建一个函数,并且函数参数是一个回调函数,相同的赋值规则同样适用——从argument(实参)到parameter(形参)只是变量赋值的另一种形式:
type Adder = (a: number, b: number) => number; function iTakeAnAdder(adder: Adder) { return adder(1, 2); } iTakeAnAdder((a, b) => { a = 'hello'; // Error: 不能把 'string' 类型赋值给 'number' 类型 return a + b; });在这个例子中,传给iTakeAnAdder的匿名回调虽然没有标注任何参数类型,但编译器根据adder: Adder的形参类型,把回调的a、b推断为number,并因此在a = 'hello'处报错。这正是 JavaScript 中最常见的"回调参数自动推断"模式——也是Array.prototype.map、forEach等 API 回调能自动获得元素类型的原因。
从源码视角看回调推断的边界
回调推断并不是无限的。在 docs/typings/typeCompatibility.md 中可以看到,两个函数相互赋值时,TypeScript 对返回类型采用协变(Covariant)比较——返回类型必须包含足够的数据;对参数数量则允许更少的参数(函数能够选择性地忽略多余的参数):
const iTakeSomethingAndPassItAnErr = (x: (err: Error, data: any) => void) => { /* 做一些其他的 */ }; iTakeSomethingAndPassItAnErr(() => null); // ok iTakeSomethingAndPassItAnErr(err => null); // ok iTakeSomethingAndPassItAnErr((err, data) => null); // ok // Error: 参数类型 `(err: any, data: any, more: any) => null` 不能赋值给参数类型 `(err: Error, data: any) => void` iTakeSomethingAndPassItAnErr((err, data, more) => null);这意味着:当你为一个回调函数提供形参位置时,编译器会尽量根据目标函数类型去推断每个参数的类型,但如果实参的参数数量超过形参,推断就会失败并报错。这也是"类型可以从左向右流动,但不能凭空多出信息"的体现。
结构化数据推断:对象字面量与数组
这些简单的推断规则同样适用于结构化的存在(对象字面量)。例如下面这种情况下,foo的类型被推断为{ a: number, b: number }:
const foo = { a: 123, b: 456 }; foo.a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型数组也一样:
const bar = [1, 2, 3]; bar[0] = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型编译器对对象字面量和数组元素逐个执行推断:a、b由123、456推断为number;数组bar的元素由[1, 2, 3]推断为number。此后对结构内部任何成员的"类型外"赋值都会被拦截。
结构推断与 Freshness(新鲜度检查)的关系
需要补充的是,当对象字面量被直接赋值给一个带注解的变量或函数参数时,TypeScript 还会启用更严格的"对象字面量检查"(Freshness)。正如 docs/typings/freshness.md 所展示的:
function logName(something: { name: string }) { console.log(something.name); } logName({ name: 'matt' }); // ok logName({ name: 'matt', job: 'being awesome' }); // Error: 对象字面量只能指定已知属性,`job` 属性在这里并不存在。注意:这种"只允许已知属性"的错误提示只会发生在对象字面量上。如果你先把对象存入一个变量再传入,则由于结构类型(Structural Typing)的存在,多出的属性是被允许的(见 docs/typings/typeCompatibility.md 中iTakePoint2D(point3D); // 额外的信息,没关系的例子)。这一差异在实战中非常常见,也是初学者最容易困惑的地方。
解构中的类型推断
这些推断规则也适用于**解构(Destructuring)**中。先看对象解构:
const foo = { a: 123, b: 456 }; let { a } = foo; a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型再看数组解构:
const bar = [1, 2]; let [a, b] = bar; a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型编译器会沿着解构的模式,把源对象/数组的成员类型一一传递给解构出来的新变量。
更进一步:如果函数参数能够被推断出来,那么解构亦是如此。在如下例子中,函数参数能够被解构为a/b成员,并且它们的类型会从形参类型自动推断:
type Adder = (number: { a: number; b: number }) => number; function iTakeAnAdder(adder: Adder) { return adder({ a: 1, b: 2 }); } iTakeAnAdder(({ a, b }) => { // a, b 的类型能被推断出来 a = 'hello'; // Error:不能把 'string' 类型赋值给 'number' 类型 return a + b; });可以看到,({ a, b }) => ...中的a、b被自动推断为number,因为它们是从形参类型{ a: number; b: number }中解构出来的。这一特性让 React 组件、Redux reducer 等重度使用解构参数的场景获得了完全的类型安全,而无需任何参数注解。
类型保护:块内变量的另一种推断形式
在前面章节 docs/typings/typeGuard.md(类型保护)中,我们已经知道它如何帮助我们改变和缩小类型范围(特别是在联合类型下)。从推断的角度看,类型保护只是一个块中变量的另一种推断形式——当条件成立时,TypeScript 会"重新推断"该变量在条件块内的更窄类型。
例如,typeof运算符在条件块中会收窄联合类型:
function doSome(x: number | string) { if (typeof x === 'string') { // 在这个块中,TypeScript 知道 `x` 的类型必须是 `string` console.log(x.subtr(1)); // Error: 'subtr' 方法并没有存在于 `string` 上 console.log(x.substr(1)); // ok } x.substr(1); // Error: 无法保证 `x` 是 `string` 类型 }instanceof与else分支的收窄同理:
class Foo { foo = 123; } class Bar { bar = 123; } function doStuff(arg: Foo | Bar) { if (arg instanceof Foo) { console.log(arg.foo); // ok console.log(arg.bar); // Error } else { // 这个块中,一定是 'Bar' console.log(arg.foo); // Error console.log(arg.bar); // ok } }此外,TypeScript 还支持基于in操作符、字面量类型(如kind: 'foo'判别联合)以及用户自定义的类型保护函数(arg is SomeType返回值形式)的推断收窄,详见 docs/typings/typeGuard.md。无论是哪种形式,其本质都是:在特定代码块内,编译器用更精确的类型覆盖了变量原本的宽泛类型,这正是推断机制在控制流层面的延伸。
警告与陷阱
尽管类型推断在多数情况下"正确且方便",但存在两个常见陷阱需要特别小心。
小心使用参数:无注解时类型不会流入函数参数
如果类型不能被赋值推断出来,类型也将不会流入函数参数中。例如下面这个例子,编译器并不知道foo的类型,所以它也就不能推断出a或者b的类型:
const foo = (a, b) => { /* do something */ };此时a、b都是隐式的any——TypeScript 放弃了推断,把类型控制权完全交给运行时。
然而,如果foo添加了类型注解,函数参数也就能被推断(a、b都能被推断为number类型):
type TwoNumberFunction = (a: number, b: number) => void; const foo: TwoNumberFunction = (a, b) => { /* do something */ };这正是前面"从左向右流动"规则的直接推论:参数类型推断必须有一个"锚点"(类型注解或上下文类型),否则推断无从谈起。
小心使用返回值:隐式 any 的污染
尽管 TypeScript 一般情况下能推断函数的返回值,但它可能并不是你想要的。例如如下的foo函数,它的返回值为any:
function foo(a: number, b: number) { return a + addOne(b); } // 一些使用 JavaScript 库的特殊函数 function addOne(a) { return a + 1; }这是因为返回值的类型被一个缺少类型定义的addOne函数所影响:addOne的参数a是any,所以addOne返回值为any,进而a + addOne(b)的结果也是any,最终foo的返回值被推断为any。一个隐式any的"脏类型"会沿调用链向上传播,让本可精确推断的返回类型退化为any,从而失去类型保护。
提示:我发现最简单的方式是明确的写上函数返回值,毕竟这些注解是一个定理,而函数是注解的一个证据。
显式标注返回类型后,编译器会在函数体内部以该类型为基准进行校验,addOne返回any的"污染"就会被隔离在foo内部,不会扩散到foo的调用方。
noImplicitAny:让隐式 any 无处遁形
这里还有一些其他可以想象的情景,但有一个好消息是:有编译器选项noImplicitAny可以捕获这些 bug。
选项noImplicitAny用来告诉编译器,当无法推断一个变量时发出一个错误(或者只能推断为一个隐式的any类型)。此时你有两种出路:
- 通过显式添加
:any的类型注解,来让它成为一个any类型; - 通过一些更正确的类型注解来帮助 TypeScript 推断类型。
在实际的 tsconfig.json 中,该选项通常与严格模式一起开启。仓库中的 docs/project/compilationContext.md 给出了完整的编译上下文配置说明,其中"严格的类型检查选项"一栏明确注释了它的含义:
/* 严格的类型检查选项 */ "strict": true, // 启用所有严格类型检查选项 "noImplicitAny": true, // 在表达式和声明上有隐含的 any类型时报错 "strictNullChecks": true, // 启用严格的 null 检查 "noImplicitThis": true, // 当 this 表达式值为 any 类型的时候,生成一个错误 "alwaysStrict": true, // 以严格模式检查每个模块,并在每个文件里加入 'use strict'推荐做法是在新项目中直接开启"strict": true(它内部隐含了noImplicitAny、strictNullChecks、noImplicitThis等全部严格选项),这样"参数无注解即报错"的约束会从第一天起生效,把隐式any的隐患扼杀在编译期。这一点也在 docs/faqs/type-system-behavior.md 中得到印证:为了避免相关问题,需要开启noImplicitAny选项,当检测到有任何参数的类型为any时,它将会发出一个警告。
延伸:推断如何与泛型、infer 协同
类型推断并不局限于"字面量初始化"这一类简单场景,它与泛型的结合才是类型系统真正强大的地方。
泛型推断:从实参推导类型参数
在 docs/typings/generices.md 中可以看到,泛型函数调用时,类型参数会根据实参自动推断,并反过来约束返回值:
function reverse<T>(items: T[]): T[] { const toreturn = []; for (let i = items.length - 1; i >= 0; i--) { toreturn.push(items[i]); } return toreturn; } const sample = [1, 2, 3]; let reversed = reverse(sample); reversed[0] = '1'; // Error reversed = ['1', '2']; // Error reversed[0] = 1; // ok reversed = [1, 2]; // okreverse(sample)中的sample是number[],TypeScript 据此把T推断为number,于是返回值reversed被推断为number[]——任何混入string的赋值都会被拒绝。
另一个典型的实战例子是getJSON<T>这种"返回 Promise"的封装。只要调用时传入泛型参数,返回值类型就能被完整推断出来,从而省去手动注解:
const getJSON = <T>(config: { url: string; headers?: { [key: string]: string } }): Promise<T> => { const fetchConfig = { method: 'GET', Accept: 'application/json', 'Content-Type': 'application/json', ...(config.headers || {}) }; return fetch(config.url, fetchConfig).then<T>(response => response.json()); }; type LoadUserResponse = { user: { name: string; email: string; }[]; }; function loaderUser() { return getJSON<LoadUserResponse>({ url: 'https://example.com/users' }); }注意:如果你使用泛型仅用于单个参数位置而没有在成员之间提供约束,那它通常是一种误用——例如declare function foo<T>(arg: T): void;并不比declare function foo(arg: any): void更安全(见 docs/typings/generices.md 中"误用的泛型"一节)。推断的价值在于约束关系,而不在于形式上的泛型化。
infer:在条件类型中反向推断
类型推断还有更进阶的形态:infer关键字。在 docs/tips/infer.md 中,它被定义为"在extends条件语句中待推断的类型变量"。看这个最简单的示例:
type ParamType<T> = T extends (arg: infer P) => any ? P : T;含义是:如果T能赋值给(arg: infer P) => any,则结果是该函数类型中的参数P,否则返回T:
interface User { name: string; age: number; } type Func = (user: User) => void; type Param = ParamType<Func>; // Param = User type AA = ParamType<string>; // stringTypeScript 2.8 起内置了一系列基于infer的工具类型,例如提取函数返回值的ReturnType<T>、提取构造函数参数的ConstructorParameters<T>与提取实例类型的InstanceType<T>:
type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any; type Func = () => User; type Test = ReturnType<Func>; // Test = Userinfer还可以配合"Distributive conditional types"玩出更多花样,例如 tuple 转 union([string, number]→string | number):
type ElementOf<T> = T extends Array<infer E> ? E : never; type TTuple = [string, number]; type ToUnion = ElementOf<TTuple>; // string | number以及利用"逆变位置上同一类型变量的多个候选会被推断为交叉类型"的特性实现 union 转 intersection(T1 | T2→T1 & T2):
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; type Result = UnionToIntersection<T1 | T2>; // T1 & T2完整的推导过程与更多用例见 docs/tips/infer.md。理解infer之后,你会意识到"类型推断"并非只能被动地观察字面量——你完全可以主动设计推断,让类型系统替你从已有结构中提取出新的类型。
小结:一张类型推断速查表
| 场景 | 推断来源 | 类型流向 | 示例(文档章节) |
|---|---|---|---|
| 变量定义 | 初始化表达式 | 右 → 左 | let foo = 123推断为number |
| 函数返回类型 | return语句 | 底部 → 顶部 | add(a, b) { return a + b }推断为number |
| 函数赋值 | 左侧类型注解 | 左 → 右 | let foo: Adder = (a, b) => ... |
| 回调参数 | 形参的函数类型 | 实参 → 形参 | iTakeAnAdder((a, b) => ...) |
| 对象/数组字面量 | 成员初始值 | 成员级逐个推断 | { a: 123 }→{ a: number } |
| 解构 | 源对象/数组成员类型 | 结构 → 局部变量 | let { a } = foo |
| 类型保护块 | 收窄条件 | 控制流重推断 | typeof x === 'string'块内x: string |
| 泛型调用 | 实参类型 | 实参 → 类型参数 → 返回值 | reverse(sample)→T = number |
infer条件类型 | 结构匹配 | 条件类型内部提取 | ReturnType<T>、ElementOf<T> |
掌握这些规则,配合noImplicitAny(推荐直接启用"strict": true)的编译期约束,你就能在享受"少写注解"便利的同时,始终获得可靠的类型安全。相关扩展阅读推荐仓库内 docs/typings/typeGuard.md(类型保护)、docs/typings/typeCompatibility.md(类型兼容性)、docs/typings/generices.md(泛型)与 docs/project/compilationContext.md(编译上下文与 tsconfig 完整配置)。
- 文档
- 教程
【免费下载链接】typescript-book-chinese
TypeScript Deep Dive 中文版
相关推荐
TypeScript 函数返回类型推断实战:从返回值自动推导类型(The Concise TypeScript Book)
TypeScript 函数返回类型推断实战:从返回值自动推导类型(The Concise TypeScript Book) 函数返回类型推断(Type from
文档教程The Concise TypeScript Book:深入理解 "Type from Value"(从值推断类型)
The Concise TypeScript Book:深入理解 "Type from Value"(从值推断类型) 在《The Concise TypeScr
文档教程TypeScript 函数返回类型推断详解:《The Concise TypeScript Book》函数返回类型推断精讲
TypeScript 函数返回类型推断详解:《The Concise TypeScript Book》函数返回类型推断精讲 《The Concise TypeS
文档教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考