类型推断

理解 TypeScript 如何从初始化值、上下文和泛型实参推导类型,并在字面量拓宽、空集合与公开 API 边界上保持正确契约。

难度 进阶 时长 标准深度约 15分钟
版本 TypeScript 6
what

类型推断(type inference) 让 TypeScript 根据初始化值、上下文和调用实参计算静态类型,不必在每个局部声明上重复注解。

trap

推断结果未必等于最精确的字面量,也不会验证运行时数据;可变位置会拓宽,缺少上下文的位置还可能产生 any

fix

明显的局部值交给推断,在公开 API、空集合和状态转换边界写出契约;用严格类型检查和声明输出验证真实结果。

是什么,为什么存在

类型推断是编译器根据已有类型信息,为表达式、变量、函数返回值或泛型类型参数计算类型的过程。它发生在类型检查阶段,生成的 JavaScript 不携带这次计算。推断减少重复语法,但不会改变运行时的值。

最直接的信息来自初始化表达式。const port = 8080 已经说明值是数字,再写 : number 通常没有增加约束。函数参数不同,因为调用函数时尚无初始化值可供函数体使用,所以普通具名函数的参数通常仍需要注解或外部上下文。

另一类信息来自表达式所在位置的预期类型,这叫作 上下文类型推断(contextual typing) 。例如,数组元素类型会给 map() 回调的参数提供类型;已注解的对象类型也能给对象字面量中的方法参数提供类型。信息因此不只从表达式内部向外流动,也会从使用位置进入表达式。

你会在变量初始化、回调、对象字面量、函数返回值、解构以及泛型调用中遇到推断。好的推断让实现代码保持简短,同时保存输入与输出之间的关系。它不是“省略所有类型”的规则,而是决定哪些位置已有足够证据。

公开边界需要单独判断。如果实现变化不应改变调用方看到的类型,就应写出参数或返回值契约,或者检查生成的 .d.ts。局部实现可以继续依赖推断,这两种选择并不矛盾。

类型推断与类型收窄相关,但不是同一个主题。推断建立初始静态类型,控制流收窄则在运行时检查之后缩小一个已有联合。复杂守卫和可辨识联合由相关主题负责,这里只讨论推断怎样为后续检查提供起点。

工作原理

检查器会从初始化值、上下文目标、返回表达式和调用实参收集候选类型,再按照当前位置的规则求出结果。结果必须既能描述当前表达式,也要允许该位置承诺的后续操作。正因如此,“最窄”不是所有位置的统一目标。

下面几类位置使用的信息不同。记住信息来源,比背诵单个示例的显示类型更可靠。

位置主要信息来源常见结果
const region = "eu"初始化值与不可重新绑定的声明原始值常保留字面量类型
let region = "eu"初始化值与可重新绑定的位置字符串字面量通常拓宽为 string
[1, "two"]所有数组元素候选可容纳各元素的联合数组
items.map(item => ...)map() 的回调签名item 取得数组元素类型
identity(value)泛型签名与调用实参为本次调用求出类型参数
function total() { return 1 }所有可达返回表达式推断函数返回类型

初始化与拓宽

不可重新绑定的原始 const 往往保留字面量类型,因为该绑定不会改成另一个值。let 变量通常需要接受同类的其他值,所以字符串、数字和布尔字面量会变成对应原始类型。这个过程叫作 字面量拓宽(literal widening)

const 只禁止重新绑定变量,不会让对象属性不可写。const request = { method: "GET" }method 通常是 string,因为之后仍能执行 request.method = "POST"。普通数组也通常推断为可变数组,而不是固定长度的只读元组。

需要保留字面量时,选择要符合生命周期。静态常量表可以使用 as const;仍要修改的状态应写成合适的联合注解;只想检查结构并保留表达式细节时可以使用 satisfies。这些工具都不执行运行时冻结或输入验证。

上下文从使用位置进入表达式

上下文类型最常见于回调。Shipment[]filter() 签名已经规定谓词接收 Shipment,因此箭头函数参数无需重复注解。把同一个箭头函数先保存到没有注解的变量中,会失去这层调用上下文。

对象字面量也能取得上下文。若变量已经声明为包含 onSuccess(message: string): void 的接口,对象中的 onSuccess(message) { ... } 会把 message 推断为 string。如果先创建无类型对象,再赋给接口,创建对象时缺少的上下文不会事后补回。

上下文不是类型断言。表达式仍须能赋给目标,拼错的属性或不兼容的返回值仍会产生诊断。断言则可能要求检查器忽略真实的不兼容,两者的证据强度相反。

多个候选与公共类型

数组元素和多个返回分支会提供不止一个候选。检查器寻找能容纳这些候选的结果,必要时形成联合。例如,[1, "two"] 通常得到 (string | number)[],而不是随意选择其中一个元素的类型。

类实例数组不一定自动提升成共同基类。如果候选是 RhinoElephantSnake,结果可能保留这些候选的联合,而不是主动猜测作者想要 Animal[]。确实需要基类契约时,应在声明位置写出 Animal[]

空集合没有元素候选,因此对上下文尤其敏感。在严格项目的无上下文边界,空数组可能触发隐式 any[] 诊断;在某些表达式位置,它也可能取得上下文元素类型。不要依赖一段孤立示例给出的万能结论,直接给空集合写元素类型。

泛型调用推断关系

泛型函数把同一个类型参数放在多个位置,检查器再从调用实参推断本次调用的替换类型。pluck<T, K extends keyof T> 会从记录数组推断 T,从键实参推断 K,返回值因此是对应的 T[K][]。这种关系是 any 无法表达的。

约束只限制候选,并提供函数体可用的成员。K extends keyof T 不会在运行时检查字符串是否为对象键,泛型语法也会在输出时擦除。来自 JSON 或 JavaScript 的值仍需运行时验证。

若多个实参向同一个类型参数提供冲突候选,结果可能成为联合、选择更宽类型,或者直接报错,具体取决于签名中的输入和输出位置。不要用强制断言掩盖结果;先确认一个类型参数是否承担了过多无关关系。

返回值与上下文目标

没有返回注解时,检查器会组合所有可达的 return 表达式。一个分支返回字符串、另一个分支不返回值,结果通常包含 undefined。调用方的期望不会自动把实现中真实存在的返回分支抹掉。

若函数表达式被赋给已有函数类型,返回表达式还会按该目标接受上下文检查。这能让错误靠近实现出现,但目标类型仍不会转换运行时值。返回数字的位置不能因为上下文要求字符串就自动变成字符串。

async 函数的推断结果会反映 Promise。函数体返回 Invoice 时,调用方看到的是 Promise<Invoice>;不同分支仍需在包装前求出兼容结果。公开异步边界通常值得显式写出 Promise<...>,以防实现错误进入契约。

直接或间接递归的函数可能没有足够顺序信息推断稳定返回类型,也更容易形成循环引用诊断。给递归入口写返回注解,既打破推断循环,也记录递归各分支必须满足的共同结果。

解构与默认值

解构变量从被解构值取得类型。const { id, total } = order 会分别保留属性类型,而数组解构会根据数组或元组的元素信息决定每个位置。普通数组没有固定位置保证,元组才会把索引与具体元素类型关联起来。

参数解构仍然需要来源。function print({ id }) {} 没有给对象参数任何类型,在严格模式下不能根据函数体使用方式反向发明完整对象契约。应给整个参数对象注解,而不是只给解构出的局部名称补断言。

默认值会参与可用性分析。可选参数在使用默认值后,函数体内通常可以按排除 undefined 后的类型使用,但调用签名仍允许调用方省略该实参。函数体类型和调用方契约描述的是两个观察位置。

对象剩余属性与数组剩余元素也会派生新类型,但动态键和索引签名可能降低精度。复杂解构一旦让错误信息难以定位,先给输入对象命名并注解,再执行浅层解构,通常比增加断言更清楚。

示例

下面四个示例依次展示初始化与拓宽、回调上下文、泛型调用推断,以及公开返回边界。每个文件都使用 TypeScript 6.0.3 以 --strict 完成类型检查,再由本地 npx tsx 执行。

观察初始化与拓宽

类型级断言验证三个推断结果。原始 const 保留字面量,而 let 和可变对象属性允许后续写入同类值。

inference-basics.ts
type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

const homeRegion = "eu-west";
let activeRegion = "eu-west";
const service = { state: "ready", attempts: 0 };

type HomeIsLiteral = Expect<Equal<typeof homeRegion, "eu-west">>;
type ActiveIsString = Expect<Equal<typeof activeRegion, string>>;
type StateIsString = Expect<Equal<typeof service.state, string>>;

activeRegion = "us-east";
service.state = "busy";
service.attempts += 1;

console.log(homeRegion);
console.log(activeRegion);
console.log(`${service.state}: ${service.attempts}`);
eu-west
us-east
busy: 1

Expect 不生成 JavaScript,但它会在推断与预期不同时让类型检查失败。这里没有使用断言把结果强行改成目标类型,因此测试的是检查器真实计算的类型。

service 变量本身不能重新绑定,属性却仍可修改。要把 state 限制为几个合法状态,应给属性联合注解或在更高层定义状态模型,而不是误以为 const 已经建立该契约。

利用回调上下文

shipments 的元素注解同时为 filter()map() 的回调参数提供上下文。第二个 map() 参数也根据标准数组签名得到 number

contextual-callbacks.ts
type Shipment = {
  id: string;
  kilograms: number;
};

const shipments: Shipment[] = [
  { id: "PK-104", kilograms: 7 },
  { id: "PK-105", kilograms: 12 },
  { id: "PK-106", kilograms: 18 },
];

const heavyLabels = shipments
  .filter((shipment) => shipment.kilograms >= 10)
  .map((shipment, index) =>
    `${index + 1}. ${shipment.id} (${shipment.kilograms} kg)`,
  );

console.log(heavyLabels.join("\n"));
1. PK-105 (12 kg)
2. PK-106 (18 kg)

回调返回模板字符串,因此 heavyLabels 推断为 string[]。数组声明提供一处领域契约,流水线内部无需重复 Shipmentnumberstring

如果把 .filter((shipment) => ...) 中的函数先单独写成 const isHeavy = (shipment) => ...,该声明没有数组方法提供上下文。在 noImplicitAny 下,shipment 会产生 隐式 any(implicit any) 诊断;修复位置是独立函数的参数,而不是调用处的断言。

从泛型实参保留属性关系

pluck() 的两个实参共同决定返回元素类型。选择 "total" 得到 number[],选择 "paid" 得到 boolean[],而实现只写一次。

generic-pluck.ts
function pluck<T, K extends keyof T>(
  rows: readonly T[],
  key: K,
): T[K][] {
  return rows.map((row) => row[key]);
}

const orders = [
  { id: "A-17", total: 42, paid: true },
  { id: "B-04", total: 19, paid: false },
];

const totals = pluck(orders, "total");
const paidFlags = pluck(orders, "paid");

const grandTotal = totals.reduce((sum, total) => sum + total, 0);
console.log(`total: ${grandTotal}`);
console.log(`paid: ${paidFlags.join(", ")}`);
total: 61
paid: true, false

K extends keyof T 让错误键在调用处失败,也让 row[key] 在实现中合法。返回类型若写成宽泛的 unknown[],这层属性关系会丢失;写成 any[] 则还会把不安全操作传播给调用方。

泛型只维护静态关系。若 key 来自命令行或网络,请先检查它确实属于当前对象允许的键集合,再进入这个已类型化函数。

固定公开返回契约

函数内部的局部变量和 reduce() 回调继续依赖推断,但公开返回类型写成 InvoiceSummary。实现重构若漏掉字段或返回错误类型,诊断会出现在函数内部。

public-boundary.ts
type InvoiceSummary = {
  count: number;
  totalCents: number;
};

export function summarizeInvoices(
  amounts: readonly number[],
): InvoiceSummary {
  const totalCents = amounts.reduce(
    (sum, amount) => sum + amount,
    0,
  );

  return { count: amounts.length, totalCents };
}

const summary = summarizeInvoices([1299, 2500, 499]);
console.log(`${summary.count} invoices`);
console.log(`${summary.totalCents} cents`);
3 invoices
4298 cents

显式边界不要求为每个局部变量补注解。totalCentssumamountsummary 都有充分信息,重复类型只会增加维护位置。

如果该函数仅供一个文件内部使用,而且实现变化可以自然改变调用方,返回类型推断也可能合适。是否注解取决于这里是不是稳定契约,而不是函数行数。

陷阱

修复方法: 先判断值是否真的不可变。常量数据用 as const,可变状态用明确联合,结构检查用 satisfies;不要为了追求窄类型给每个表达式添加断言。

修复方法: 在空集合创建处写出元素契约,例如 const jobs: Job[] = []。这既避免隐式 any,也让首次写入错误立即落在来源处。

修复方法: 让短回调保持内联,或者给可复用回调写参数与返回类型。不要用 any 消除 noImplicitAny 诊断,因为那会取消回调体内的检查。

修复方法: 对稳定公开 API 明确注解返回值,并在发布库时审查 声明输出(declaration emit) 。实现内部仍可大量使用局部推断。

修复方法: 立即把不可信结果放进 unknown,执行对象、字段和领域规则验证,再构造领域值。类型推断只能传播已有静态证据,不能创造运行时证据。

深入 推断结果是静态事实

推断结果是静态事实

推断得到的是检查器对表达式的静态描述,不是运行时附加到值上的标签。const count = 3 输出后仍只是 JavaScript 数字,运行时代码无法询问它曾被推断为 3 还是 number。类型擦除也意味着推断本身没有输入验证功能。

静态类型取决于检查时可见的声明。一个错误的第三方 .d.ts 可以让推断结果非常具体,却与运行时完全不符。精确不等于真实,证据链还必须包含声明质量与外部边界验证。

any 会污染这条证据链。对 any 的属性访问、调用和赋值通常继续产生 any,于是代码不报错并不是推断质量高,而是检查器被要求放弃证明。诊断推断问题时,应先寻找最上游的 any 来源。

unknown 的行为相反。它可以接收任意值,但不会允许依赖具体类型的操作,直到控制流检查或验证器提供证据。外部输入使用 unknown,能让后续具体类型代表一次可审查的升级。

空集合与控制流演化

无元素的数组字面量没有候选元素。若目标位置提供 Job[],它就能按该元素类型检查;若没有上下文,严格选项可能报告隐式 any[]。在某些局部控制流中,检查器还会根据后续写入演化数组的观察类型。

这种演化是分析规则,不是运行时数组改变了类型。不同读取点可能看到不同的控制流信息,导出边界也不能依赖所有调用方都经历同一组写入。因此,领域集合不应把首批 push() 当作公开契约。

空对象有类似的建模问题。const settings = {} 的推断类型没有后来凭空出现的属性,逐步给它添加字段通常会报错。若构建过程确实分阶段进行,可以使用明确的构建器、局部可选状态或一次性完整对象,而不是宽断言。

初始值 null 也不足以表达完整生命周期。在严格空值检查下,稍后要保存 Connection 的变量应写成 Connection | null。注解不是和推断对抗,而是在初始化值无法表达未来合法状态时补足契约。

泛型候选的所有权

一个类型参数应表达一项可命名的关系。pluck() 中的 T 代表记录类型,K 代表记录键,两者所有权清楚。若一个 T 同时控制输入、默认值、回调返回值和缓存内容,任何一处变化都可能改变其他位置的推断。

输入位置通常为类型参数提供候选,上下文目标有时也会影响推断。约束会拒绝不满足能力要求的候选,却不会自动把结果变成约束本身。T extends { id: string } 仍应尽量保存实参的其他字段。

显式类型实参适合表达检查器无法唯一决定、但调用方确实拥有的选择。它不应作为常规消错按钮。若 load<Account>(raw) 内部只是把 JSON.parse 断言为 T,调用方写出的类型实参没有提供任何运行时证据。

推断失败时,先给中间结果命名并查看编辑器或声明输出显示的实际类型。然后检查类型参数是否过多、约束是否放错位置、实参是否提前拓宽,以及返回上下文是否真的属于契约。最后才考虑显式类型实参。

用类型查询记录推断结果

typeof 出现在类型位置时,可以取得某个值的静态类型。它不会执行 JavaScript 的运行时 typeof 检查,也不会返回字符串。配合索引访问和 keyof,代码可以从一份源码数据派生后续类型。

这种派生避免手写结构漂移,但也会把源值的拓宽结果带下去。若配置对象的属性已经推断为 string,稍后使用 typeof config.mode 无法恢复最初的字面量。需要的精度必须在源声明处保留。

类型级测试可以把推断结果变成编译门槛。常见做法是定义 EqualExpect 一类仅在检查阶段存在的辅助类型,或者写预期成功和带 @ts-expect-error 的预期失败赋值。升级编译器时,这些测试能暴露推断规则变化。

不要把大型推断类型原样当作文档。给重要领域概念命名,并在公开签名中展示调用方需要理解的部分;typeof 适合保持事实同步,不应把内部对象的每个偶然细节都暴露为 API。

编译选项属于推断环境

同一段源码在不同编译选项下可能得到不同诊断或可用类型。noImplicitAny 决定缺少信息时是否允许静默出现 anystrictNullChecks 决定 nullundefined 是否保持独立成员。讨论推断结果时必须说明有效配置。

strict: true 提供强基线,但不包含所有更保守选项。noUncheckedIndexedAccess 会让未证明存在的索引读取包含 undefined,从而改变后续表达式看到的类型。它不是推断算法之外的附注,而是输入给检查器的契约环境。

命令行参数、继承的 tsconfig 和编辑器使用的项目都可能改变有效配置。排查“编辑器与 CI 推断不同”时,先运行 tsc --showConfig 并确认文件属于哪个项目,再比较编译器版本。只看源码无法解释配置分歧。

发布库时还要用支持范围内的最低编译器测试声明。较新编译器可以生成旧版本无法解析或无法同样推断的类型语法。版本兼容是发布契约的一部分,不能只以作者编辑器中的结果为准。

声明输出暴露推断结果

启用 declarationemitDeclarationOnly 后,编译器会把导出声明的可见类型写入 .d.ts。没有显式返回注解的导出函数会把推断结果变成发布契约,所以实现细节可能进入版本控制、包产物和调用方诊断。

这不表示每个导出都必须手写完整类型。小型常量表有时正需要把精确字面量公开出去,泛型工厂也可能依赖推断保存关系。关键是团队是否有意接受生成声明中的具体形状。

库评审应把声明输出当作构建产物检查。对修改前后执行相同的 TypeScript 6 声明生成命令,比较导出签名,并为预期接受与拒绝的调用保留类型测试。只运行 JavaScript 测试看不到这类契约漂移。

应用内部也可借用同一方法定位复杂推断。临时导出一个中间声明并生成 .d.ts,常比从错误信息猜测更直接。诊断完成后应删除临时导出,避免它意外成为真实 API。

注解的放置原则

参数通常需要注解,因为函数体必须在不知道未来调用实参的情况下接受检查。回调参数若有可靠上下文则可省略;一旦回调成为独立声明,它就拥有自己的边界。位置不同,所需证据也不同。

返回类型注解适合稳定接口、递归函数和需要在实现处发现漂移的函数。局部辅助函数可以依赖推断,尤其当返回形状只是附近实现的自然结果。统一要求所有函数都写或都不写返回类型,会丢掉边界差异。

变量注解适合空集合、完整生命周期宽于初始值的状态,以及需要主动拓宽为接口的对象。初始化值已完整表达意图时,重复原始类型通常没有收益。注解应该增加约束或稳定性,而不是统计上的覆盖感。

最后检查错误落点。好的边界会让不兼容实现尽早在生产者处失败,让错误实参在调用处失败,并让外部坏数据在解析处失败。如果错误只能在很远的消费者中出现,通常说明推断链缺少一个应明确写出的契约。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?