为什么类型写对了还是报错:TypeScript 推断的四个岔路口

TypeScript 的类型检查从来不是单向的:表达式自下而上产生类型,上下文类型自上而下约束写法,控制流分析沿分支收窄,泛型调用点则在实参之间做一次统一。四条路各走各的,冲突时就会冒出"明明写对了却报错"或"明明报错却跑得通"。最常见的误解是把类型当成运行时校验、把 as 当成类型转换、把 any 当成万能逃生门。读完这篇,你能判断一处报错是加宽、上下文丢失还是收窄失效造成的,并知道在哪些边界上该显式标注、哪些地方可以放心交给推断。

是什么

TypeScript 的类型系统有两个底色:结构化与可擦除。结构化指两个类型只要成员形状兼容就能互相赋值,跟类型叫什么名字、有没有显式继承无关;可擦除指所有类型在编译成 JavaScript 后被整体删掉,运行时一个字节都不剩。一句话直觉:类型是编译期的一张契约草图,而推断是编译器拿着这张草图,从你写的表达式里反推它"应该"是什么形状。

推断无处不在:函数参数不写类型也能用,是因为实参类型被反推回去;回调里的参数自动有类型,是因为外层签名把期望类型灌了下来;const config = { mode: "dark" } 传给只收 "dark" | "light" 的函数却报错,是因为属性被加宽成了 string。这些不是玄学,而是推断在四个岔路口替你做了决定。搞清这四个岔路口,比背一百个工具类型有用。

核心原理

岔路口一:字面量加宽还是保留

推断的第一件事是决定"要不要把字面量放大成它的基类型"。let x = "idle" 得到 string,因为变量之后可能被改成别的字符串;const x = "idle" 得到 "idle",因为没有再赋值的可能。对象与数组的属性默认也走加宽:const cfg = { mode: "dark" } 的类型是 { mode: string },不是 { mode: "dark" }。于是把它传给 (c: { mode: "dark" | "light" }) => void 会失败——编译器认为 cfg.mode 将来可能是任意字符串。

as const 解决的是这一类问题:它把整棵对象树变成 readonly 的字面量类型,深层也生效。satisfies(TypeScript 4.9 起)解决的是另一类:先按目标类型做兼容性检查,但保留表达式原本更窄的推断结果。二者经常一起用,前者冻结字面量,后者校验形状。

岔路口二:上下文类型从哪来

推断不只自下而上,还自上而下。赋值右侧、实参位置、返回值位置都会把"期望类型"传给表达式,这叫上下文类型(contextual typing)。[1, 2, 3].map(x => x.toFixed()) 里 x 能直接调 toFixed,是因为 map 的签名把 (value: number, index: number, array: number[]) 作为期望类型传给了箭头函数。

这也是为什么把回调先抽成独立变量常常失效:const fn = x => x.toFixed() 里没有上下文,x 只能是隐式 any,开了 noImplicitAny 就直接报错。上下文类型一旦断开,推断就只能靠表达式自身,很多"我明明写对了"的报错都出在这里。

岔路口三:控制流把联合类型削窄

声明类型是"所有可能",控制流分析(control flow analysis)算的是"走到这一行时还剩哪些可能"。typeof、instanceof、in、判别属性、真值判断、=== 比较、断言函数以及返回 x is T 的自定义谓词,都会让后续分支里的类型变窄。

收窄有两个容易忽略的边界。第一,它只对当前作用域内、且之后没有被重新赋值的变量成立;变量一旦被闭包捕获,编译器无法保证回调真正执行时它还是那个类型,收窄通常会被撤销(TypeScript 5.4 起,对"最后一次赋值后再没改过"的变量,闭包里的收窄得以保留)。第二,收窄是"路径敏感"的:在 if 里做的判断,出了 if 就未必还成立,除非提前 return 形成了守卫。

岔路口四:泛型统一与条件类型分配

调用泛型函数时,编译器先从实参收集候选类型,再挑一个能兼容所有候选的公共类型;推不出来就退到类型参数的约束,没有约束则退到 unknown。这个"统一"过程是双向的:返回值位置有期望类型时,它也会反过来约束类型参数。

条件类型 T extends U ? X : Y 还多一条规则:当 T 是裸的类型参数时,传入联合类型会逐个成员分配,再把结果并起来;把两边包成元组 [T] extends [U] 就能关掉分配。这是写工具类型时最常见的翻车点之一。

const config = { mode: "dark", retries: 3 } as const;
// config.mode 的类型是 "dark",不是 string

type Mode = "dark" | "light";
const ok: Mode = "dark";

declare function pick<T, K extends keyof T>(obj: T, key: K): T[K];
const picked = pick({ a: 1, b: "x" }, "b"); // string

type Spread<T> = T extends unknown ? T[] : never;
type R = Spread<"a" | "b">; // "a"[] | "b"[]

let v: string | number = Math.random() > 0.5 ? "s" : 1;
if (typeof v === "string") v.toUpperCase(); // 此处 v 是 string

这段代码里四个岔路口各出现一次:as const 阻止加宽,pick 演示泛型从实参反推,Spread 演示分配律,最后两行是控制流收窄。

常见考点与误区

  • 误区一:"类型会在运行时检查":不会。类型在编译后全部擦除,as 也不是类型转换,它只是告诉编译器"按这个类型看我",运行时的值一个字节都没变。面试问"如何做运行时类型校验",正确方向是类型守卫、zod 一类解析库或 instanceof,而不是 as。
  • 误区二:"any 和 unknown 差不多":差得远。any 与任何类型双向兼容,还会把污染扩散到调用链下游;unknown 接受任何值,但用之前必须收窄,是边界处更安全的选择。never 是空集:它可以赋给任何类型,但除它自己以外没有类型能赋给它,所以适合放在穷尽检查的 default 分支里。
  • 误区三:"结构类型意味着 private 也只看形状":错。带 private 或 protected 成员的类,只有当这些成员来自同一处声明时才互相兼容,效果上带了一点名义类型的味道。这是"两个类字段一模一样却不能互赋"的常见原因,也是面试里区分结构类型与鸭子类型的好切口。
  • 误区四:"interface 和 type 只是写法不同":大部分场景可以互换,但 interface 支持声明合并、可被 extends 继承,适合对外暴露可扩展的对象形状;type 能表达联合、映射、条件、模板字面量类型,适合做类型运算。给库写公共 API 时,这个选择会影响使用方能不能扩展。
  • 误区五:"条件类型总是对联合分配":只有裸类型参数才分配。type F<T> = T extends any ? T[] : never 传 string | number 得到 string[] | number[];改成 [T] extends [any] 就只得到 (string | number)[]。
  • 误区六:"数组协变是安全的":TypeScript 允许把 string[] 赋给 (string | number)[],但往里 push 一个 number 就破坏了原数组。strictFunctionTypes 只把函数类型位置的参数检查改成逆变,方法语法仍按双变处理——这是类型系统出于可用性做的妥协,不是保证。

实践建议

  • 打开 strict 全家桶,再补两个开关:strict 已包含 strictNullChecks、strictFunctionTypes、noImplicitAny 等;索引访问建议再加 noUncheckedIndexedAccess,让 arr[0] 的类型带上 undefined,逼你在边界处处理空值。
  • 局部靠推断,边界靠标注:函数体内的变量不必重复写类型;但导出的函数、公共 API、跨模块契约要显式写返回类型,否则实现一改,类型就悄悄漂移,使用方在升级时才炸。
  • 用 satisfies 校验配置,用 as const 冻结常量:主题表、路由表、事件名这类"既要字面量精度又要结构校验"的对象,as const satisfies Record<string, ...> 比裸 as 稳得多。
  • 状态用可辨识联合建模,穷尽检查用 never:每个状态带一个字面量 tag,switch 走完所有分支后在 default 里把值赋给 never,将来新增状态会立刻编译报错。
  • 给类型写测试:用 tsd、expect-type 或在 vitest 里用 expectTypeOf,把关键推断结果固化成断言;CI 里跑 tsc --noEmit,别只依赖编辑器。
  • 别过度炫技:深层递归条件类型会显著拖慢 tsc,遇到大面积变慢先用 --extendedDiagnostics 定位,再考虑简化或拆分。

参考:TypeScript 类型收窄、类型推断。

一句话总结:TypeScript 的推断是双向的,报错往往不是类型写错,而是你没意识到编译器在哪个岔路口替你做了另一个选择。

相关阅读

← 返回文章志