☰
TypeScript条件语句深度解析:从值分流到类型收窄
2026/9/28 8:48:05 网站建设 项目流程

写TypeScript的人,十个里有九个会觉得自己早就把条件语句玩明白了——无非是if、else、switch、三元运算符,JavaScript里写了十几年,换到TypeScript还能翻天不成?我第一次上手的时候也是这个心态,直到在某次代码评审里被一段联合类型加类型收窄的写法当场看懵,才意识到同一个if语句,在TS里发挥的作用和JS里完全是两码事。

这篇内容就是把TypeScript条件语句从值到类型、从基础到进阶完整拆开讲一遍:值层面的if/else、switch、三元、短路运算怎么选,类型层面的收窄(narrowing)如何让编译器帮你分流,再往上走一步,条件类型(conditional types)和infer又是怎么把“条件”搬进类型系统里的。最后还会结合常见反模式和我实际重构过的代码,聊聊怎么写才能少踩坑。适合刚接触TypeScript的前端开发者、准备面试的同学,以及在团队里推TS优化代码质量的朋友。

1. 条件语句在TypeScript里到底有什么可讲的

先抛一个反直觉的结论:在TypeScript里写条件语句,真正的价值往往不在“控制流程”,而在“类型分流”。JS里写if (input === 'dog'),只是让代码在运行时走不同分支;TS里写同样一行,编译器还会在分支内部自动把input的类型从联合类型收窄成具体的某一个。这种“边执行边收窄”的机制,才是TS条件语句和JS条件语句最本质的区别。

举个最朴素的例子:

type Animal = | { kind: 'dog'; bark: string } | { kind: 'cat'; meow: string }; function speak(animal: Animal) { if (animal.kind === 'dog') { // 这里不需要任何断言,TS自动知道 animal 是 dog 类型 console.log(animal.bark.toUpperCase()); } else { // 进入 else 后,animal 被收窄为 cat 类型 console.log(animal.meow.toLowerCase()); } }

在if (animal.kind === 'dog')为真的分支里,animal.bark可以安全访问,编译器替你确认了属性存在;在else分支里,animal自动变成了cat,连补丁都不用打。这个体验放在JavaScript里是不可能的——那里只有运行时才知道bark到底存不存在。

所以,聊TS条件语句需要分两个维度去理解:

  • 值层面:用户写的各种条件表达式、分支语句,最终编译成JS后照样运行,这部分和JS没有本质区别。
  • 类型层面:TS编译器利用条件表达式来“收窄”一个变量的类型范围,或者利用条件类型在类型空间里做“分类讨论”。

这两个维度是咬合在一起的。只懂值层面,你写出来的TS就是披着类型外衣的JS;只懂类型层面,你写出来的东西又容易悬空、不落地。真正高质量的TS代码,往往是两类条件语句配合使用:运行时用值条件分流,编译期用类型条件推导。

我在团队里带新人的时候经常打一个比方:条件语句就像机场安检口的分类闸机。值层面的if/else是“人走到哪个闸机就往哪边走”,类型收窄则是“登机牌上提前印好了你该去哪个登机口,检票的同时系统自动根据闸机口更新你的有效信息”。TS的优雅就在这里——闸机不仅分流,还顺手把每个人的行李清单换成了对应航线的版本。

2. 值层面基础:if/else、switch、三元与短路运算的选型

把类型层面放一放,先把值层面的几种写法捋清楚。这几个虽说是JS老知识,但在TS里一旦带上类型,选型逻辑会发生一点微妙变化。

2.1 if/else:最通用,但要小心变量生命周期

if/else没有任何限制,适合任意条件表达式,是万金油。不过在TS里它有一个非常容易踩的细节:如果在一个if分支里声明了一个变量,这个变量在分支外是不可见的;但如果用let在外部声明、在分支里赋值,TS对它的类型推断往往是“联合类型”,需要再收窄。

let result: string; if (condition) { result = 'yes'; } else { result = 'no'; } // 这种情况TS能通过,因为两个分支都赋值了

更麻烦的是异步场景:

let data: string[] = []; if (needFetch) { // 假设 fetchData 返回值类型是 string[] data = await fetchData(); } // data 此时的类型还是 string[],TS不会因为你只是可能在某个分支里赋值 // 就给你一个 string[] | undefined

这个问题其实不算bug,但很多人一开始写的时候会觉得“TS为什么不能更智能一点”。与其纠结,不如直接用函数返回或者表达式来替代,后面会讲到。

2.2 switch:适合“同源多分支”的判别

switch适合对一个变量做多分支等值判断,尤其是可识别联合类型配合使用时特别爽。但要注意,老式switch有个“落空”问题——没写break会继续往下执行,TS在类型判断上也会顺着走,容易把变量收窄到错误的类型上。与其写容易漏break的句子,我更推荐用[JS的switch新写法]或者直接上对象映射(后面会展开)。

一个典型的可识别联合加switch写法:

type Event = | { type: 'click'; x: number; y: number } | { type: 'keydown'; key: string } | { type: 'scroll'; offset: number }; function handleEvent(e: Event) { switch (e.type) { case 'click': console.log(e.x, e.y); break; case 'keydown': console.log(e.key); break; case 'scroll': console.log(e.offset); break; } }

每个case里,TS都会自动把e收窄到对应分支的联合成员,访问属性时会有自动提示。这种模式比手写一长串if/else判断type字段要清晰得多。

2.3 三元运算符和逻辑短路:从“语句思维”切换到“表达式思维”

在TS项目里,我更偏好用三元和短路来处理简单的二选一或默认值逻辑。原因是三元和短路返回的是“表达式”,可以直接赋值、直接参与组合,而if/else是“语句”,只能挂在括号里。写类型收窄时,表达式天然更有利于TS做推断:

const displayName = user.nickname ?? user.name ?? '匿名用户'; const level = score >= 90 ? 'expert' : score >= 60 ? 'pass' : 'fail';

这里??和||的区别要特别注意。||只要左侧是falsy就会取右侧,而??只有当左侧是null或undefined时才取右侧。在TS里,0、''、false都是合法的业务值,因此处理可空值我基本只用??,避免把合法值误判成空值。

如果是多层条件组合,务必控制好嵌套深度。超过两层的三元建议拆成辅助函数,否则可读性会断崖下跌。

2.4 TS与JS条件语句的差异清单

维度JavaScriptTypeScript
运行时行为无类型,自己保证分支安全编译后和JS一致,但编译期做类型检查
分支内变量类型无法感知自动收窄,属性访问更安全
穷举检查无switch配合可识别联合可做完备性检查
默认值处理常手动判空可选链、空值合并、类型守卫三者配合
错误分支运行时才发现类型错误在编译期暴露

这份对照表是面试里非常喜欢问的点,尤其是“TypeScript条件语句和JavaScript相比有什么区别”,如果你能说出“不仅仅是加类型,而是多了一套类型收窄和穷举检查”,面试官会眼前一亮。

3. 类型收窄:让编译器在分支里替你“分流”

这是TS条件语句最核心的机制,没有之一。理解收窄,才能理解为什么TS里很多if写法看起来是多余的,却又是必须的。

3.1 收窄是什么

收窄(narrowing)指的是:TS根据控制流分析,在一个分支范围内把一个变量的类型从较宽的范围缩小到较具体的范围。最常见的入口是联合类型判断。

function process(input: string | number) { if (typeof input === 'string') { // 这里 input: string console.log(input.trim()); } else { // 这里 input: number console.log(input.toFixed(2)); } }

没有这个机制,你就只能到处用as断言,等于手动告诉编译器“别管了,我知道答案”,结果就是类型保护形同虚设。

3.2 四种常见的收窄方式

  • typeof收窄:适合原始类型判断,但注意它只能区分string、number、boolean、bigint、symbol、function、object等有限集合。null会被typeof判断为object,这是个历史包袱,别踩进去。
  • instanceof收窄:适合类实例判断,比如error instanceof Error。
  • in收窄:适合判断对象上有没有某个属性,在处理不完全相交的对象类型时特别好用。
  • Array.isArray收窄:专门处理数组判断,等价于一个内置的类型守卫。

看一个综合示例:

type UnknownData = string | number[] | { items: string[] } | null; function handle(data: UnknownData) { if (data === null) { return; // data: null 分支先处理掉 } if (typeof data === 'string') { return data.length; // data: string } if (Array.isArray(data)) { return data.length; // data: number[] } if ('items' in data) { return data.items.length; // data: { items: string[] } } }

注意我把null判断放在了最前面,这非常重要。很多人喜欢在最后处理空值,导致前面的分支类型判断仍可能带着null,TS会提示某些属性访问不安全。

3.3 可识别联合:switch之外的正解

可识别联合(discriminated union)是TS里把条件语句和类型系统结合得最漂亮的模式之一。它的核心是:每个联合成员都有一个字面量类型的判别字段(通常是type或kind),TS可以靠这个字段精确收窄整个联合的剩余成员。

type Result<T> = | { status: 'success'; data: T } | { status: 'error'; message: string }; function handleResult<T>(result: Result<T>) { if (result.status === 'success') { // result: { status: 'success'; data: T } console.log(result.data); } else { // result: { status: 'error'; message: string } console.error(result.message); } }

更进阶的玩法是配合never做穷举检查。在default分支里把变量赋给never,如果未来有人往联合里加了新成员但忘了处理,TS会在编译期直接报错:

function assertNever(x: never): never { throw new Error('Unexpected value: ' + x); } function getEventName(event: Event): string { switch (event.type) { case 'click': return 'click'; case 'keydown': return 'keydown'; case 'scroll': return 'scroll'; default: return assertNever(event); // 新增类型时这里会编译报错 } }

这个方法我强烈建议在团队里推行,成本极低,但能拦住大量“改完类型忘了改逻辑”的事故。

3.4 自定义类型守卫:用 is 写你自己的收窄

有时判断逻辑并不像typeof或in那么简单。比如你想判断一个对象是不是“带id的用户”,原生收窄做不到,就需要写自定义类型守卫:

interface User { id: number; name: string; } function isUser(value: unknown): value is User { return ( typeof value === 'object' && value !== null && 'id' in value && 'name' in value ); } function doSomething(input: unknown) { if (isUser(input)) { // input 在这里被收窄为 User console.log(input.name); } }

关键就在返回类型上的input is User。这个语法告诉TS:只要函数返回true,参数的类型就被收窄成User。注意守卫函数内部不能做不一致的断言,否则TS的信任会被击穿,运行时容易出问题。写守卫时要尽量覆盖边界情况,比如null、undefined、非对象原始值,最好都显式处理。

3.5 可空性防守:从双重断言到顺手收窄

可空处理是TS条件语句里最高频的应用。先看一个错误示范:

function getFullName(user?: { name?: string | null }) { // 坏习惯:直接断言非空 const name = (user?.name as string) ?? ''; return name; }

这种写法把user?.name断言成string,其实完全绕过了TS的检查。如果user.name是null,as string并不会让null消失,运行时依然可能得到null。用断言不是不行,但要在确认逻辑正确的前提下谨慎使用,否则就是自欺欺人。

推荐的做法是显式收窄:

function getFullName(user?: { name?: string | null }) { const name = user?.name; if (name == null) { return '佚名'; } return name; }

这里有两点值得展开:

  • user?.name可选链会一路把undefined传递下去,比写user && user.name清爽得多。
  • 判断空值用name == null而不是name === undefined。== null同时匹配null和undefined,这是少数值得用的宽松相等场景。我见过太多人写name === undefined || name === null,写两次不如一次。

一句话总结:可空性防守的核心思路是“先清空值域,再做业务”。进函数先处理空值早退,后面就能安全操作,代码也平铺直叙得多。

4. 把“条件”搬进类型系统:条件类型与infer实战

如果说类型收窄是TS条件语句的“运行时”,那条件类型就是TS在编译期写的“类型层代码”。这一块属于进阶内容,但在面试和复杂工具类型里几乎是必考的。

4.1 条件类型的语法:T extends U ? X : Y

条件类型的形态一眼就能看懂,冒号两侧分别对应条件成立和不成立的类型:

type IsString<T> = T extends string ? true : false; type A = IsString<'hello'>; // true type B = IsString<123>; // false

这个写法和三元运算几乎一样,但运算对象是“类型”而不是“值”。T extends string表示“T能不能赋值给string类型”,不是“T是不是继承自string”。这里最容易混淆。

我曾经在代码评审里看到有人写类型来做“数组是否包含某元素”的判断,结果写了一百行类型体操。其实日常业务中条件类型最常见的用法是做工具函数返回值的精确推导,而不是追求类型体操难度。

4.2 分发条件类型:泛型联合类型自动触发分发

条件类型有一个容易被忽略的机制:当T是一个联合类型时,条件类型会“分发”到联合的每个成员上。看例子:

type ToArray<T> = T extends any ? T[] : never; type Result = ToArray<string | number>; // 结果是 string[] | number[],而不是 (string | number)[]

这个分发的行为有时是惊喜,有时是惊吓。如果你希望得到的是一个整体数组而不是联合拆分结果,可以用一个方括号把泛型包起来:

type ToArrayNonDistributive<T> = [T] extends [any] ? T[] : never; type Result2 = ToArrayNonDistributive<string | number>; // 结果是 (string | number)[]

面试常问“为什么条件类型结果和预期不一样”,八成就是在大意上漏看了分发行为。

4.3 infer:在条件类型的“真分支”里反推类型

infer关键字是条件类型里最迷人的部分。它允许你在extends匹配成功时“反推”出一个中间类型,并用这个中间类型构建新类型。

最常见的例子是提取函数返回值和数组元素类型:

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never; type GetArrayItem<T> = T extends Array<infer U> ? U : never; type A = ReturnType<typeof fetchData>; // 自动提取 fetchData 的返回值类型 type B = GetArrayItem<string[]>; // string

infer的命名很有意思,它是“推断”的意思。你可以把它理解为类型层的一个“临时变量”,只在分支匹配时有效。实际项目里我常用它编写reducer状态的精确推导,避免在多个地方重复手写某个派生类型。

4.4 实用案例:手写一个DeepReadonly

学了一堆语法,最后还是要在代码里落地。我建议从写一个DeepReadonly开始练手,它几乎用上了条件类型的所有核心点:

type DeepReadonly<T> = T extends Function ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]>; } : T;

这个类型做的事情:如果T是函数,原样返回;如果是对象,把每个属性变为readonly并递归处理;否则原样返回。写完之后你可以在业务上直接用它创建“只读配置”:

interface Config { server: { host: string; port: number; features: string[]; }; } type ReadonlyConfig = DeepReadonly<Config>;

之后任何往这些属性上赋值的操作都会在编译期被拦下来。这对大型配置对象、全局状态管理非常实用。

不过也提醒一句:条件类型别滥用。如果某个类型逻辑复杂到团队里没人能看懂,不如在代码里写一个显式的辅助类型加注释,可维护性永远比炫技重要。

5. 业务代码里最常见的条件反模式与我的重构习惯

条件语句的坑往往不在语法上,而在写法习惯上。下面几类反模式我在代码评审中见得太多了,列出来供你对号入座。

5.1 嵌套地狱:用卫语句提前返回

三层起步的嵌套if在JS时代就能把人绕晕,在TS里还会拖累类型收窄——分支越深,TS的收窄分析越复杂,可读性越差。我的习惯是函数开头先处理所有异常/空值/边界条件,用“卫语句”让主体逻辑保持平铺:

// 反模式 function processOrder(order?: Order) { if (order) { if (order.status === 'paid') { if (order.items.length > 0) { // 处理逻辑 } } } }

重构后:

function processOrder(order?: Order) { if (!order) return; if (order.status !== 'paid') return; if (order.items.length === 0) return; // 到这里,order 已被收窄为 Order,且状态正确 }

效果几乎一样,但可读性大幅提升。更重要的是,每早退一次,TS的收窄范围就更干净一次,后面代码不需要反复判空。

5.2 魔法状态与字符串比较:用字面量联合类型限制取值

业务里最常见的条件判断是状态判断,但直接拿字符串散落在各处的写法非常危险:

// 反模式 if (user.status === 'ACTIVE') { } if (order.status === 'shipped') { } if (config.env === 'PROD') { }

这些字符串没有任何类型约束,拼错一个字母就在运行时悄悄出bug。TS里正解是把状态定义成字面量联合类型:

type UserStatus = 'ACTIVE' | 'INACTIVE' | 'BANNED'; type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered'; type Env = 'development' | 'test' | 'production';

有了联合类型后,编译器会帮你穷举所有可能的状态,switch或if判断时还能获得自动补全。更重要的是,任何不在预期范围内的状态值在编译期就会报错,不会等到线上崩溃。

5.3 滥用as断言绕过类型收窄

这是我在团队里最想消灭的习惯。一个典型例子:

// 反模式 const age = (input as any).age; const name = (someObject as unknown as User).name;

as any或双重断言会在类型收窄上开一个口子,等于告诉TS“这里你不用管”。一旦出口打开,后续的类型检查全部失效,条件语句写得再多也是摆设。

正确的思路是让数据在源头就有类型。如果数据来自外部接口,先定义一个合理接口类型,再做收窄;如果数据确实可能是多种形态,就用可识别联合加类型守卫,而不是断言。

我见过有人辩解“断言省事”,但省下的时间最后都会在重构或排查线上bug时加倍还回来。这一点在团队协作中尤为重要,因为你无法保证下一个接手的人跟你一样清楚哪些断言是安全的。

5.4 条件分支的单元测试:别放过else路径

条件语句写得多,测试就要覆盖得全。我给团队定的最低标准是:每一个if分支至少一个用例,每一个switch的case至少一个用例,可识别联合的每个成员都要跑到。

拿类型守卫举例:

describe('isUser', () => { it('应该识别完整用户对象', () => { expect(isUser({ id: 1, name: 'Tom' })).toBe(true); }); it('应该拒绝缺少name字段的对象', () => { expect(isUser({ id: 1 })).toBe(false); }); it('应该拒绝null和原始值', () => { expect(isUser(null)).toBe(false); expect(isUser('str')).toBe(false); }); });

很多人只测“成立”的分支,不测“不成立”的分支。但类型守卫的要点恰恰在于,守卫返回true之后TS会信任它。如果这个信任建立在错误逻辑上,就会造成类型正确但运行时崩溃的诡异问题。

测试条件分支的时候,建议把目光放在“边界数据”上:空对象、缺失字段、null、undefined、额外字段。这些才是条件判断最容易失守的地方。

6. 从TS 7.0配置弃用提示说起:那些和条件语句看似无关、实则影响深远的环境问题

最近升级TypeScript版本时,很多人会看到这样的编辑器提示:

  • 选项“baseUrl”已弃用,并将停止在TypeScript 7.0中运行。
  • 选项“moduleResolution=node10”已弃用,并将停止在TypeScript 7.0中运行。

这个提示乍一看和条件语句八竿子打不着,但它影响的其实是“模块解析”这套机制,而这套机制恰好和你写的条件编译、导入导出、甚至运行时分支行为都有关系。

6.1 为什么会被弃用

baseUrl加上moduleResolution: node10(旧称node)是很多老项目从JS迁移到TS时留下的默认配置。node10模拟的是Node.js 10时代的CommonJS解析规则,查找模块时会尝试目录下的index.ts、没有扩展名的文件等。这套规则在纯CommonJS时代够用,但随着ESM普及、exports字段出现,解析场景变得复杂很多,继续沿用旧规则反而会给出错误或过时的类型查找结果。

简单理解:node10解析规则是“老地图”,而现在的模块生态已经是“新城市”,地图不更新就会迷路。

6.2 对你的条件语句代码有什么影响

有的项目会用条件导入或环境判断来区分不同模块来源,例如:

// 旧习惯:依赖绝对路径导入 import { helper } from 'src/utils/helper'; // 新习惯:基于Node的exports字段路由 import { helper } from '@myapp/shared';

baseUrl一旦废弃,类似src/utils/helper这种基于baseUrl的绝对路径导入就要全部改成相对路径,或者改用路径映射方案。如果你之前的条件分支里有动态导入、环境变量判断,那么模块解析方式的改变可能会让某个分支在特定环境下加载不到对应模块。这不是条件语句写法本身错了,而是底层导入解析的“路由表”变了。

6.3 迁移建议

我的处理方案是分两步走:

第一步,把tsconfig.json里的moduleResolution明确改成bundler或node16/nodenext。如果项目是前端构建工具驱动(Vite、Webpack等),选bundler最合适;如果是纯Node.js项目,选node16或nodenext。

{ "compilerOptions": { "moduleResolution": "bundler", "module": "esnext", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true } }

第二步,逐步清理baseUrl依赖的绝对路径导入,改用paths映射或相对路径。这一步可以直接用IDE的重构功能辅助,但建议分批改,每改一批跑一遍类型检查和构建,避免一次性改动太大造成回归。

从我实际迁移的一个Vue3项目中得到的经验是:这类配置迁移一般两三天能完成,收益却很大——类型检查更准确、导入路径更可维护、未来的TS 7.0升级不再有阻塞。

最后再多说一句和条件语句直接相关的体会:无论配置怎么变,代码里条件分支的核心价值始终是“让逻辑清晰、让类型安全”。写完一段条件语句,建议自己退一步读一遍——分支是平的还是有深坑?类型是收窄的还是到处断言?状态是穷举的还是靠猜的?这三点过关了,你写出来的TS条件语句基本就够职业水准了。我在实际操作中的习惯是,碰到拿不准的收窄时机,就去看编译结果和类型提示,让编译器当我的第二双眼睛,这比反复猜类型高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询