本节目标:理解编译器在「没有标注」时如何猜出类型。读完你能说清自下而上推断与上下文类型的方向差异、知道字面量拓宽何时发生、看懂泛型推断候选的选取顺序,并能在推导失败时判断该补标注还是改结构。
1.3 类型推导算法与上下文类型
前两节讲了兼容性与擦除,它们都建立在「类型已经存在」这个前提上。但真实代码里大量地方没有标注——const x = [1, 2, 3]、items.map(i => i.id)、useState(0),类型从哪来?本节把推导过程拆成两条方向相反的流水线,并给出失败时的诊断路径。
一、两条相反的推导方向
编译器的类型推导有两个方向,理解它们的差别是本节的主线:
| 方向 | 名称 | 数据来源 | 典型场景 |
|---|---|---|---|
| 自下而上 | 推断(inference) | 表达式的值 | const n = 1 |
| 自上而下 | 上下文类型(contextual typing) | 期望的类型 | 回调参数、对象字面量 |
| 双向 | 双向检查(bidirectional) | 两者结合 | const f: Handler = e => {} |
const n = 1; // 自下而上:从值 1 推出 number
const arr = [1, 2, 3]; // 自下而上:number[]
[1, 2, 3].map((x) => x * 2); // 自上而下:x 由 map 的签名推出 number
记住一个判据:类型是「从值的形状算出来」的,还是「从期望的位置倒推回来」的。前者由表达式本身决定,后者由它出现的位置决定——同一个箭头函数,换个位置就可能推出完全不同的参数类型。
const inc = (x) => x + 1;
// 报错:Parameter 'x' implicitly has an 'any' type.(没有上下文可用)
const nums = [1, 2, 3];
const inc2 = nums.map((x) => x + 1); // x: number(由 map 签名提供上下文)
二、自下而上:字面量拓宽
推导出的类型不一定是最精确的。编译器会对可变位置的字面量做拓宽(widening):
let a = 'hello'; // string(拓宽)
const b = 'hello'; // "hello"(保持字面量)
const c = { k: 'v' }; // { k: string }(对象属性被拓宽)
规则可以概括为:const 声明的原始字面量保持字面量类型,let/var 与对象属性一律被拓宽。想让整个对象都保持字面量,用 as const:
const cfg = { mode: 'dark', retries: 3 } as const;
// 类型:{ readonly mode: "dark"; readonly retries: 3 }
as const 是唯一能让推导结果变窄的语法,在类型级编程里出现频率极高。它做三件事:把字面量保持为字面量类型、给所有属性加 readonly、把数组变成 readonly 元组。
const dirs = ['up', 'down'] as const;
type Dir = (typeof dirs)[number]; // "up" | "down"
三、最佳公共类型:数组与联合的合并
数组字面量的元素类型不一致时,编译器求最佳公共类型(best common type):
const mixed = [1, 'a', true]; // (string | number | boolean)[]
const objs = [{ a: 1 }, { a: 2, b: 3 }]; // ({ a: number; b?: undefined } | { a: number; b: number })[]
第二种结果常常出人意料。规则是:编译器会尝试为每个候选找到其他候选都能赋给的类型;找不到时,退化为联合类型,并对缺失的属性补 ?: undefined。所以这类数组元素最好显式标注:
interface Item { a: number; b?: number }
const objs: Item[] = [{ a: 1 }, { a: 2, b: 3 }]; // 干净且稳定
空数组是特例,它是 never[],只有在后续写入或上下文类型介入时才会被「定型」:
const empty = []; // any[](noImplicitAny 下使用时报错)
const nums: number[] = []; // 推荐:显式标注,避免 any 扩散
四、上下文类型:回调参数从哪来
回调参数是最典型的上下文类型场景。编译器拿到调用目标的签名后,反向把参数类型喂给箭头函数:
interface ClickEvent { type: string; payload: unknown }
function on(evt: 'click', cb: (e: ClickEvent) => void): void {}
on('click', (e) => {
e.type.toUpperCase(); // e 被推导为 ClickEvent
// e.notExist; // 报错:Property 'notExist' does not exist on type 'ClickEvent'
});
两个关键细节:
- 上下文类型不参与重载解析的实参推断。只有当函数签名的参数类型已经确定时上下文才有效;若参数类型本身依赖泛型参数,上下文可能失效,
e会退化为any或报隐式 any。 - 上下文可以被显式标注覆盖,但方向必须兼容,否则报错:
type Cb = (e: { type: string }) => void;
const cb: Cb = (e: { type: string; extra: number }) => {};
// 报错:参数不兼容——目标只承诺传入 { type }
上下文类型还会向下穿透多层结构,让对象字面量的每个字段都获得期望类型:
interface Options { retries: number; onError: (err: Error) => void }
const opts: Options = {
retries: 3,
onError: (err) => console.error(err.message), // err 由上下文推出 Error
};
五、泛型推断:从实参反推类型参数
泛型函数调用时,编译器先从实参收集推断候选,再选出最合适的一个:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const n = first([1, 2, 3]); // T = number
const s = first(['a', 'b']); // T = string
const m = first([1, 'a']); // T = string | number(候选无法互相赋值,取联合)
多条候选的合并规则:
| 场景 | 结果 |
|---|---|
| 多个候选可互相赋值 | 取「更宽」的那个 |
| 无法互相赋值 | 取联合类型 |
候选含 any | any 会污染整个推断 |
| 候选为空 | 回退到约束的上界,或 unknown |
需要从多个位置共同推断时(例如 zip),编译器会同时收集两组候选:
function zip<A, B>(as: A[], bs: B[]): [A, B][] {
return as.map((a, i) => [a, bs[i]]);
}
zip([1, 2], ['a', 'b']); // [number, string][]
显式指定会完全跳过推断,所以显式类型必须自己写对:
const x = first<number>(['a']);
// 报错:Argument of type 'string[]' is not assignable to parameter of type 'number[]'
复杂泛型的推断成本会显著上升,量级与优化手段见 类型实例化开销与测量 与 泛型 API 设计与性能 。
六、推导失败:隐式 any 与循环引用
最常见的失败是「没有上下文、也没有初值」,编译器只能给 any,并在 noImplicitAny 下报错:
function handle(input) {}
// 报错:Parameter 'input' implicitly has an 'any' type.
第二种失败是循环推导——类型依赖自己的推导结果:
const f = () => f();
// 报错:'f' implicitly has type 'any' because it does not have a type annotation
// and is referenced directly or indirectly in its own initializer.
这类递归必须靠显式标注打断循环:
const g: () => number = () => g(); // OK,循环被标注切断
第三种失败是自引用对象,编译器会直接拒绝:
const self = { me: self };
// 报错:'self' implicitly has type 'any' because it does not have a type annotation
诊断顺序建议:先看是否缺上下文(回调、参数),再看是否循环引用(自身调用),最后才考虑补显式标注。多数情况下前两者能解释你看到的 any。
七、推导失败的其他形态
除了隐式 any 与循环引用,还有几种失败不那么显眼,却同样影响类型精度:
// 1. 解构默认值会拓宽
const { mode = 'dark' } = options; // mode: string(不是 "dark")
// 2. catch 变量在 useUnknownInCatchVariables 下是 unknown
try { risky(); } catch (e) { e.message; }
// 报错:'e' is of type 'unknown'
// 3. 索引访问在 noUncheckedIndexedAccess 下带 undefined
const list: string[] = [];
const head = list[0]; // string | undefined
第一种要在解构处显式标注才能保住字面量:
const { mode = 'dark' as const } = options;
第二种的正确写法是先收窄:
try { risky(); } catch (e) {
if (e instanceof Error) console.error(e.message);
}
第三种不是「失败」,而是 noUncheckedIndexedAccess 故意加上的安全边界——它把「数组越界返回 undefined」这一运行时事实写进了类型。是否打开取决于团队对越界访问的容忍度。
八、上下文类型与收窄的配合
上下文类型给出「初始类型」,控制流分析则在此之上做收窄(narrowing)。两者配合,才构成完整的「类型随时间变化」的体验:
function fmt(v: string | number | null): string {
if (v === null) return '-'; // v: null
if (typeof v === 'number') {
return v.toFixed(2); // v: number
}
return v.trim(); // v: string
}
判别联合是让收窄最稳定的结构,因为收窄依据是字面量字段的值而不是类型名:
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2;
case 'rect': return s.w * s.h;
}
}
注意 switch 的两个分支已经覆盖全部 kind,若将来新增一个 kind,area 会在返回类型检查下暴露出来——这是判别联合比 instanceof 更适合边界分支的原因(见 类型擦除与运行时边界
)。
九、断言函数与推导的收窄边界
除了类型谓词,TS 3.7 起还支持断言函数(assertion function):它不返回布尔值,而是告诉编译器「函数正常返回就意味着某个条件成立」。
function assertIsString(v: unknown): asserts v is string {
if (typeof v !== 'string') throw new Error('not a string');
}
function upper(v: unknown): string {
assertIsString(v);
return v.toUpperCase(); // v 已被收窄为 string
}
它与类型谓词的分工是:需要分支时用谓词(if (isX(v))),需要「不满足就抛」时用断言函数。两者都是编译期的承诺,函数体写错编译器只检查返回类型(void),不会验证逻辑。
另一个常被混淆的是 satisfies 与推导的关系:satisfies 不改变推导结果,只在推导完成后做一次兼容校验。
const cfg = { port: 8080, host: 'localhost' } satisfies Record<string, string | number>;
// cfg.port 的类型仍是 number(推导出来的),而不是 string | number
如果写成 const cfg: Record<string, string | number> = { ... },cfg.port 就会退化成联合类型,失去精度。这是 satisfies 在配置对象场景最实用的价值:校验与精度兼得。
十、泛型默认值与推导的交互
类型参数可以有默认值,但默认值只在推断不出候选时生效,它不会阻止推断:
function createStore<S = Record<string, unknown>>(init?: S): S {
return (init ?? {}) as S;
}
const s1 = createStore({ count: 0 }); // S = { count: number }(推断优先)
const s2 = createStore(); // S = Record<string, unknown>(回退到默认值)
这条规则常被误用:以为写了默认值就「有兜底」,实际上只要调用点传了实参,默认值就完全不参与。若希望默认值总是生效,必须显式传类型参数,或拆成两个重载。
另一个常见交互是上下文类型与空数组,例如框架里的状态钩子:
const [n, setN] = useState(0); // n: number,由实参推出
const [list, setList] = useState<string[]>([]); // 空数组必须显式给类型参数
useState([]) 会推出 never[],所以空数组场景必须显式指定——这正是「空数组是特例」在真实框架里的体现。
十一、什么时候必须显式标注
推导是便利,不是契约。以下位置建议强制标注,否则重构时类型会悄悄漂移:
| 位置 | 原因 |
|---|---|
| 导出的函数返回值 | 防止内部改动意外改变公共类型 |
| 空数组与空对象字面量 | 避免 any[] / {} 扩散 |
| 递归函数的返回类型 | 打断循环推导 |
| 库的公共 API 参数 | 保证错误信息可读、IDE 提示准确 |
| 需要精确保留字面量的常量 | 用 as const,而不是依赖推导 |
| 解构默认值 | 避免默认值被拓宽成 string |
关于「显式标注会不会拖慢编译」,可延伸阅读 复杂泛型的重构手法 与 泛型 API 设计与性能 ;推导结果如何被后续的条件类型消费,见 条件类型与分发 。
小结
本节把「没有标注时类型从哪来」拆成两条流水线:
- 自下而上负责从值推出类型,并决定是否拓宽;
as const是唯一的收窄开关。 - 自上而下(上下文类型)负责把期望类型喂给回调与字面量;它依赖签名已确定,且不参与重载推断。
- 泛型推断是两者的交汇:从实参收集候选、合并、再结合约束。推导失败时按「缺上下文 → 循环引用 → 补标注」的顺序排查。
第 1 章到这里结束。我们已经建立起三块地基:兼容性怎么判、类型在运行时还剩什么、类型是怎么被推出来的。下一章转向类型层面的计算能力——从 条件类型与分发 开始,看类型如何像函数一样接收输入、产出结果,并最终构成图灵完备的类型级程序。
阅读导航:上一节:1.2 类型擦除与运行时边界 · 下一节:2.1 条件类型与分发 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。