《TypeScript高级编程》1.3 类型推导算法与上下文类型

本节讲清编译器在没有标注时如何猜出类型。内容涵盖自下而上推断与自上而下上下文类型这两条相反的流水线、字面量拓宽与 as const 的收窄作用、数组最佳公共类型的合并规则、泛型推断候选的收集与合并顺序、回调参数获得类型的条件,以及隐式 any 与循环推导这两类失败的诊断路径。读完你能判断某处该补显式标注还是该改结构,并解释常见推导结果为何与直觉不同。

本节目标:理解编译器在「没有标注」时如何猜出类型。读完你能说清自下而上推断与上下文类型的方向差异、知道字面量拓宽何时发生、看懂泛型推断候选的选取顺序,并能在推导失败时判断该补标注还是改结构。

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'
});

两个关键细节:

  1. 上下文类型不参与重载解析的实参推断。只有当函数签名的参数类型已经确定时上下文才有效;若参数类型本身依赖泛型参数,上下文可能失效,e 会退化为 any 或报隐式 any。
  2. 上下文可以被显式标注覆盖,但方向必须兼容,否则报错:
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(候选无法互相赋值,取联合)

多条候选的合并规则:

场景结果
多个候选可互相赋值取「更宽」的那个
无法互相赋值取联合类型
候选含 anyany 会污染整个推断
候选为空回退到约束的上界,或 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 设计与性能 ;推导结果如何被后续的条件类型消费,见 条件类型与分发 。

小结

本节把「没有标注时类型从哪来」拆成两条流水线:

  1. 自下而上负责从值推出类型,并决定是否拓宽;as const 是唯一的收窄开关。
  2. 自上而下(上下文类型)负责把期望类型喂给回调与字面量;它依赖签名已确定,且不参与重载推断。
  3. 泛型推断是两者的交汇:从实参收集候选、合并、再结合约束。推导失败时按「缺上下文 → 循环引用 → 补标注」的顺序排查。

第 1 章到这里结束。我们已经建立起三块地基:兼容性怎么判、类型在运行时还剩什么、类型是怎么被推出来的。下一章转向类型层面的计算能力——从 条件类型与分发 开始,看类型如何像函数一样接收输入、产出结果,并最终构成图灵完备的类型级程序。

阅读导航:上一节:1.2 类型擦除与运行时边界 · 下一节:2.1 条件类型与分发 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes