类型缩小与控制流分析
很多人第一次学联合类型时都会产生一种挫败感。不是不会写 string | number,而是写完之后发现"怎么什么都不能用了"。这其实不是 TypeScript 在刁难你,而是类型系统在提醒一件很重要的事:你已经承认这个值有多种可能,那在真正使用它之前,就必须先确认当前到底是哪一种。
这就是类型缩小存在的意义。它不是额外附加的一套技巧,而是联合类型真正能落地使用的前提。
什么是类型缩小
类型缩小可以用一句话概括:通过代码中的判断,把一个宽泛类型收窄为当前分支里更具体的类型。
ts
function printId(id: string | number) {
if (typeof id === "string") {
console.log(id.toUpperCase());
} else {
console.log(id.toFixed(2));
}
}
这里的 id 在函数入口处是 string | number,但进入 if 分支后,它被缩小成了 string;进入 else 分支后,它被缩小成了 number。
这就是 TypeScript 非常核心的一种工作方式:它不只是看类型声明本身,也会结合你的控制流分支去推断当前上下文里到底还能剩下哪些可能。
为什么没有类型缩小,联合类型几乎没法用
假设你写:
ts
function handle(value: string | number) {
return value.toUpperCase();
}
这段代码报错不是因为 toUpperCase() 不好,而是因为 number 分支不具备这个方法。联合类型的代价就在这里:只要某个能力不是所有成员共有的,你就必须先确认当前成员。
也正因如此,联合类型越重要,类型缩小就越重要。现实项目中你会发现,这两者几乎是成对出现的。
最常见的缩小方式
typeof
适合基础类型判断:
ts
function formatValue(value: string | number | boolean) {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number") {
return value.toFixed(2);
}
return value ? "true" : "false";
}
typeof 简单直接,是很多基础分支判断的第一选择。
instanceof
适合类实例判断:
ts
function printError(error: Error | string) {
if (error instanceof Error) {
console.log(error.message);
} else {
console.log(error);
}
}
只要你在处理类实例、异常对象、自定义类,instanceof 都很常见。
in
适合对象结构判断:
ts
type Dog = { bark: () => void };
type Cat = { meow: () => void };
function speak(animal: Dog | Cat) {
if ("bark" in animal) {
animal.bark();
} else {
animal.meow();
}
}
当联合类型的差异体现在属性结构上时,in 非常好用。
真值判断也会触发缩小,但要小心语义
ts
function printLength(value?: string) {
if (!value) return;
console.log(value.length);
}
这里的 if (!value) return 会让后面的 value 自动排除 undefined 和空字符串等假值可能。TypeScript 会参与这种控制流分析。
但要注意,真值判断虽然方便,却可能混淆"空字符串"和"未定义"这两种业务语义不同的情况。比如一个表单输入为空字符串,与一个字段根本不存在,未必是一回事。
所以工程上最好问自己:我现在是想排除所有假值,还是只想排除 undefined / null?
判别联合是最值得掌握的模式
如果你只记住类型缩小的一种工程写法,那应该是判别联合。它的思路非常简单:给联合类型中的每个分支都加一个稳定且唯一的判别字段。
ts
type SuccessResponse = {
status: "success";
data: string[];
};
type ErrorResponse = {
status: "error";
message: string;
};
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse) {
if (response.status === "success") {
console.log(response.data);
} else {
console.log(response.message);
}
}
这里的 status 就是判别字段。它有几个明显优势:
- 可读性极强
- 分支逻辑自然
- 非常适合接口响应、组件状态、任务状态
- 和业务语义高度一致
一旦你开始写前端状态机、请求状态、表单提交流程、任务调度,这种模式几乎无处不在。
用 kind、type、status 这类字段做判别,通常比"猜结构"更稳
很多人喜欢通过"某个字段在不在"去缩小类型,例如用 in 判断 message 或 data。这可以用,但长期来看不如专门设计判别字段稳定。
原因很简单:
- 结构可能演化
- 字段可能重名
- 某些分支可能后来也出现这个字段
如果你从一开始就设计一个明确的判别字段,类型和业务都会更清楚。
用户自定义类型守卫
有时候判断逻辑会重复出现。这时可以把它封装成类型守卫函数:
ts
type Admin = { role: "admin"; permissions: string[] };
type Member = { role: "member"; points: number };
function isAdmin(user: Admin | Member): user is Admin {
return user.role === "admin";
}
有了这个函数之后:
ts
function printUserInfo(user: Admin | Member) {
if (isAdmin(user)) {
console.log(user.permissions);
} else {
console.log(user.points);
}
}
这种写法的价值在于,它不只是复用逻辑,也把"这个判断一旦成立,类型应该如何收窄"一起复用了。
控制流分析:TypeScript 会跟着你的程序路径走
TypeScript 的强大,不只是看单个 if 判断,而是会跟踪更长的控制流。
ts
function printLength(value?: string) {
if (!value) {
return;
}
console.log(value.length);
}
这里 return 之后,TypeScript 会认为剩下分支中的 value 一定已经被过滤过了。这就是控制流分析。
类似的还有:
- 提前
throw switch分支- 多层条件判断
- 逻辑运算后的结果
它本质上意味着:TypeScript 不是静止地看类型,而是在模拟代码路径。
穷尽检查是类型缩小的高级实践
当你有一个判别联合,并且希望确保未来新增分支时不会漏处理,可以用 never 做穷尽检查:
ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number };
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius * shape.radius;
case "rectangle":
return shape.width * shape.height;
default: {
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
}
如果以后你新增 triangle 却忘了在 switch 中处理,never 检查就会直接提示你。这个技巧在大型项目里非常实用。
一个常见误区:我明明知道它是什么,为什么还报错
这是初学者最常说的一句话。答案很简单:你"脑子里知道"没有用,TypeScript 只相信代码里被明确表达出来的事实。
类型系统不会读心,也不会根据你的业务背景自动猜测。它要的是可以验证的条件。你想让它信服,就必须把判断逻辑写出来。
工程建议:别把类型缩小看成麻烦,它其实在逼你把分支说清楚
很多人把类型缩小当作额外工作量。实际上,它往往是在暴露原本就存在但被忽略的业务分支。比如:
- 一个接口到底可能成功还是失败
- 一个字段到底可能为空还是缺失
- 一个对象到底是管理员还是普通成员
这些分支本来就存在。TypeScript 只是逼你承认并处理它们。
本文小结
类型缩小是连接"联合类型"和"可执行逻辑"的桥梁。联合类型负责表达可能性,类型缩小负责在分支里确认现实。你真正掌握 TypeScript,不是因为你会写 A | B,而是因为你知道在代码路径中如何把这个联合安全地收窄成一个可操作的具体形态。
如果说联合类型让你开始描述复杂状态,那么类型缩小则让这些状态真正进入程序流程。它是 TypeScript 走向工程实用性的第一道门槛。
练习
- 写一个函数,接收
string | string[],分别输出长度,并比较typeof与Array.isArray的使用方式。 - 设计一个带
kind字段的图形联合类型,包含圆形、矩形和三角形,并根据kind计算面积。 - 写一个类型守卫函数
isSuccessResponse,让它能帮助你缩小接口返回类型。
后记
2026年5月21日于上海。