资讯动态

TypeScript联合类型与交叉类型:类型体操的基石与实战

发布时间:2026/9/24 21:22:21 来源:尧图企业网站定制
联合类型与交叉类型TypeScript 类型体操里最容易被低估的两个基础动作写 TypeScript 写了几年之后我慢慢发现一个有意思的现象很多人在入门阶段能分清interface和type的区别能背出泛型的几个花式写法但一碰到“联合类型”和“交叉类型”这两个词就开始含糊了。不是说不知道语法而是不知道什么时候该用|什么时候该用更不知道这两个操作符在深层类型推导中到底扮演了什么角色。坦白讲这两个类型操作是 TypeScript 类型系统里最基础、却最容易被低估的“地基”。你要是只写业务代码可能觉得它们就是“或”和“且”的关系好像也没啥可讲的。但等你真正开始封装通用组件、设计 SDK、处理第三方类型定义、或者尝试写点类型体操的时候你会发现所有高级技巧的底层逻辑几乎都能拆解成联合类型和交叉类型的组合。这篇文章不打算讲花哨的类型体操表演我会从实际开发的角度出发把这两个类型操作的本质、用法、坑和实战套路一次讲透。1. 内容整体设计与思路拆解为什么必须先搞清楚“或”与“且”先说一个很多教程没有点透的观点联合类型和交叉类型不是“语法糖”它们是 TypeScript 类型系统的两个基本代数运算。你写的每一个复杂类型本质上都是在做这两种运算的组合。搞不清楚这一点后面学的infer、条件类型、映射类型都会有一种“看懂了但用不会”的感觉。1.1 联合类型和交叉类型的真实含义联合类型用|连接语义是“或”一个值可以是类型 A也可以是类型 B也可以是 A 和 B 的交集。从集合论的角度看联合类型对应的是并集。string | number表示这个值要么是 string要么是 number。交叉类型用连接语义是“且”一个值必须同时满足类型 A 和类型 B 的所有约束。它对应的是交集。A B在对象类型的场景下通常表现为“把 A 和 B 的属性合并到一起”。这里我建议你用“点菜”来理解。联合类型像是“主食二选一”你可以选米饭也可以选面条但一次只能选一个而且不能选个“米饭面条混合体”。交叉类型像是“套餐包含”既包含主食又包含饮料点一份就俩都有。但这只是入门理解。真正要弄明白的是为什么string | number不是string number如果string和number交叉结果其实是never因为没有任何值能同时既是字符串又是数字。这是理解这两个操作符深层语义的关键起点——联合和交叉的最终结果取决于操作数在“值空间”上的重叠关系。1.2 为什么很多项目里类型写着写着就失控了我在代码审核时经常看到一种情况项目里到处都是any或者一堆粗糙的interface接口字段全靠复制粘贴最后改一处别的跟着崩。这种问题的根子往往不是开发者不会写类型而是没有建立“类型组合”的思维。举个例子一个常见的场景是接口返回的数据有三种状态加载中、成功、失败。如果你分别定义三个独立的接口然后用type State LoadingState | SuccessState | ErrorState来组合这就是用联合类型来建模“同一个位置可能出现多种合法形态”。反过来如果一套完整的表单配置其中一半来自默认值配置另一半来自用户自定义配置你可能会写type Config DefaultConfig UserConfig这就是用交叉类型把两块信息拼成一个完整可用对象。这两种操作是类型设计的基本功。你先想清楚“这个类型描述的对象在真实世界里是‘多选一’还是‘合体’”再决定用|还是类型就不会乱。我在带团队时定的规矩很简单凡是“状态”或“分支”优先联合凡是“装配”或“合并”优先交叉。2. 联合类型不只是“或”它是状态建模的主力很多人对联合类型的理解停留在“一个变量可以存多种类型”这种程度但这远远不够。联合类型的真正价值在于它让 TypeScript 能精确表达数据的每一种合法形态并通过类型收窄Type Narrowing跟你代码里的if分支形成闭环。2.1 基本用法与常见误区别把联合当“宽松”新手最常见的误解是string | number这样的类型很宽松等于“什么都行”。其实恰恰相反联合类型是精确的——它精确地列出了所有允许的值形态任何在这个集合之外的类型都会被拒绝。我们看一个具体例子type InputValue string | number | null; function convertToNumber(value: InputValue): number { // 这里的 value 可能是 string、number 或 null if (value null) { return 0; } if (typeof value string) { return parseFloat(value); } return value; }这里 TypeScript 能够通过typeof和 null把联合类型一步一步收窄最终在最后一行value只可能是number可以直接返回。这种从“多个可能”到“唯一确定”的过程就是类型收窄。平时写代码时if分支越明确类型收窄的效果就越好代码也就越安全。另一个常见误区是混淆“可选字段”和“联合类型”。{ name?: string }和{ name: string | undefined }语义上并不完全一样。前者表示“这个字段可能不存在”后者表示“这个字段一定存在但值可能是 undefined”。在开启exactOptionalPropertyTypes的项目里这两者的差异会直接导致编译错误。联合类型描述的是值的形态不是键的存在性。2.2 可辨识联合Discriminated Union写业务状态的首选模式聊联合类型绕不开可辨识联合。这是一种通过一个共用字段通常是type、kind或status来区分联合分支的写法。它是我在业务代码里用得最频繁的类型设计模式。interface IdleState { status: idle; } interface LoadingState { status: loading; startTime: number; } interface SuccessState { status: success; data: string[]; } interface ErrorState { status: error; message: string; } type RequestState IdleState | LoadingState | SuccessState | ErrorState; function handleState(state: RequestState) { switch (state.status) { case idle: // 这里只能访问 state.status不能访问 data break; case loading: console.log(state.startTime); // 只有 loading 分支能访问 startTime break; case success: console.log(state.data.length); // 只有 success 分支能访问 data break; case error: console.error(state.message); // 只有 error 分支能访问 message break; } }这种写法的好处非常直观你不需要在每种状态下都定义一堆可空字段每个分支的字段互不干扰代码清晰度直接上一档。而且后续加新状态时TypeScript 会强制你在switch里补全新分支的处理如果你的switch有default分支并赋给never类型这是我常用来保证状态枚举穷尽的高招function assertNever(value: never): never { throw new Error(Unexpected state: ${value}); } function handleState(state: RequestState) { switch (state.status) { case idle: case loading: case success: case error: return; default: assertNever(state); // 如果新增了状态但没处理这里的编译会报错 } }这个assertNever技巧说它是“联调期救星”毫不夸张。有一次项目里在RequestState里加了一个cancelled状态整个应用所有请求相关的处理函数编译错误一下子全暴露出来我们顺着错误一个一个补分支十几分钟就改完了。如果当初用的是散漫的对象加一堆?这种遗漏可能到了线上才被用户发现。所以我在设计数据类型时只要是有多个形态的都默认用可辨识联合而不是在单个 interface 里堆一堆可空字段。2.3 联合类型在数组和泛型里的“分配律”联合类型还有一个很容易被忽略但极其重要的特性条件类型中联合类型的分配律。简单说当T是一个联合类型时T extends X ? A : B会把T的每一个成员分别代入计算再把结果联合起来。type ToArrayT T extends unknown ? T[] : T[]; // 这里的结果是 string[] | number[]而不是 (string | number)[] type Result ToArraystring | number;这个特性在写类型工具时很好用但也容易踩坑。如果你想得到一个(string | number)[]就必须用方括号把泛型包住打破分配type ToArrayNonDistributiveT [T] extends [unknown] ? T[] : T[]; type Result2 ToArrayNonDistributivestring | number; // (string | number)[]我在第一次遇到这个坑时花了整整一个下午才搞明白为什么Result不是预期的数组联合。这个细节在实际写类型工具的时候特别常用比如ExcludeT, U的内部实现就是基于分配律实现的type MyExcludeT, U T extends U ? never : T; type R MyExcludea | b | c, a; // b | c理解了联合类型的分配行为你对 TypeScript 类型系统的理解会上一个台阶。它不是简单的“或”而是一台能做分布式计算的机器。3. 交叉类型合并属性之外还有隐藏的冲突问题交叉类型在对象类型上表现得非常“舒服”像拼积木一样把多个对象的属性拼在一起。但它的底层语义远比表面复杂尤其是同名属性冲突的处理是很多人没搞清楚的盲区。3.1 交叉类型的基本用法从混入到装配最基础的用法就是把两个对象类型合并成一个。假设你有一个基础的用户信息还有一个权限信息interface UserInfo { id: number; name: string; email: string; } interface Permission { role: admin | editor | viewer; permissions: string[]; } // 交叉后拥有 UserInfo 和 Permission 的所有字段 type UserWithPermission UserInfo Permission; const user: UserWithPermission { id: 1, name: 张三, email: zhangsanexample.com, role: admin, permissions: [read, write, delete] };这种用法的价值在于组合复用。你不用为一个“带权限的用户”重新定义一个新 interface只要把两个基础类型拼起来就行。如果再配合泛型可以做出非常灵活的类型工厂type WithTimestampsT T { createdAt: Date; updatedAt: Date; }; type Product { id: string; price: number; }; type ProductWithTimestamps WithTimestampsProduct;这种“包装式”的交叉类型写法在写通用函数和 HOC高阶组件时经常碰到。典型的场景是一个接受任意对象并返回带时间戳对象的函数function addTimestampsT extends object(obj: T): WithTimestampsT { const now new Date(); return { ...obj, createdAt: now, updatedAt: now }; } const product { id: p1, price: 99 }; const enriched addTimestamps(product); // enriched 的类型是 { id: string; price: number } { createdAt: Date; updatedAt: Date }这时 TypeScript 能正确推导出enriched既有原始属性又有时间戳属性开发体验极好。这也是交叉类型相对于interface extends的一个重要区别——交叉类型可以作用于任意类型包括泛型参数、联合类型、内置工具类型的结果而 interface 的 extends 只能用于接口或类。3.2 同名属性冲突交叉结果不一定是“合并”这么简单交叉类型最迷惑人的地方是两个类型存在同名属性但类型不同的时候。很多人以为交叉类型会把两个属性“合并”成一个更宽的类型但实际规则是同名属性会再次参与交叉运算。interface A { value: string; id: number; } interface B { value: number; name: string; } type C A B; // 这里的 value 类型是 string number也就是 never这会导致什么后果当你尝试创建一个C类型的对象时value字段会要求你同时赋一个字符串和数字而这是不可能的。结果就是整个类型变得不可用。也许你会觉得这种规则很“不合理”但如果你从集合论的角度想就通了交叉类型要求值同时满足两个类型的约束那么value这个字段既要兼容string又要兼容number没有值能做到于是就是never。实际工作中这种冲突经常出现在配置合并的场景。比如一个基础配置来自 UI 组件库另一个自定义配置来自业务方interface DefaultConfig { size: small | medium | large; disabled: boolean; } interface CustomConfig { size: string; // 业务方想写得更宽松 theme?: light | dark; } type FinalConfig DefaultConfig CustomConfig; // size 的类型变成了 (small | medium | large) string // 实际上是 small | medium | large这里size交叉后的结果并不是never因为small | medium | large与string的交集就是small | medium | large本身。但反向的类似情况就危险了如果DefaultConfig.size是small | medium | large而CustomConfig.size是xl那交叉结果就是never这个配置项会直接废掉。所以我的经验是在合并由不同方提供、可能重复定义某些字段的类型时要格外小心交叉类型对同名属性的处理。更稳妥的方案往往是使用实用工具类型Omit先把冲突字段剔除再交叉type FinalConfig OmitDefaultConfig, size CustomConfig;这样size字段完全由CustomConfig决定就不存在冲突了。当然前提是你有意让某一方覆盖另一方的配置。你再想想这种“覆盖”语义和对象展开合并{ ...defaults, ...custom }的行为倒是一致的——实际上很多配置合并函数就是这么实现的。3.3 交叉类型与接口继承的选择在面向对象风格的代码里你可能更习惯用interface extends来实现类似的效果interface Base { id: string; } interface User extends Base { name: string; }那么interface extends和交叉类型该怎么选我个人有几个判断标准优先用 interface extends 的场景需要声明类实现关系class Person implements User类型需要被反复扩展和叠加且希望冲突时直接报错代码风格偏 OOP可读性优先优先用交叉类型的场景操作的对象是泛型参数操作的对象是联合类型、字面量类型、内置工具类型的结果需要把type别名和映射类型的产物动态组合特别注意一点交叉类型对同名属性的冲突是“静默”地变成never而interface extends对同名属性不兼容时会直接编译报错。这意味着如果你希望冲突尽早暴露用 interface 更安全如果你希望类型由运行时逻辑主导交叉类型更灵活但也更容易埋雷。4. 联合类型与交叉类型的高级混用类型体操的真正地基前面我们把两个操作分开讲了但实际开发中它们经常嵌套出现。一个复杂的类型往往是交叉里套联合、联合里套交叉。4.1 分配律的实战应用从联合类型中抽取组合结构联合类型与交叉类型在一起最经典的一个场景是 TypeScript 里实现“从若干接口中选一个与公共属性合并”的效果。比如一个事件系统里不同类型的事件除了各自的 payload 之外还有公共的eventName和timestampinterface ClickEventPayload { x: number; y: number; } interface KeyPressEventPayload { key: string; code: number; } // 这个类型会把每个 payload 分别和公共字段交叉再联合起来 type ClickEvent { type: click; timestamp: number } ClickEventPayload; type KeyPressEvent { type: keypress; timestamp: number } KeyPressEventPayload; type AppEvent ClickEvent | KeyPressEvent;这时候AppEvent的每一个分支都各自拥有完整的字段。你在switch里判断type之后对应分支的 payload 字段也能被正确识别。这种写法等价于直接写一个完整的联合类型但通过交叉类型组合出来之后公共字段和分支字段是分开定义的更利于复用。如果事件很多还能用映射类型来批量生成interface EventMap { click: ClickEventPayload; keypress: KeyPressEventPayload; focus: { element: HTMLElement }; } type AppEventUnionK extends keyof EventMap keyof EventMap K extends keyof EventMap ? { type: K; timestamp: number } EventMap[K] : never; type AppEvent AppEventUnion; // 这里的结果是三个分支的联合每个分支各有自己的 payload这个技巧把“映射类型 条件类型 交叉类型 联合类型”全都串起来了看起来复杂但拆开看每一步都是最基础的运算。你只要把每一步的输出写出来就能理解整个链路的推导过程。4.2 泛型约束中的交叉从 T 到“T 与某个结构”泛型约束里最常见的一个场景是函数参数既要有某种公共能力又允许额外的自由属性。如果你的业务数据来自外部 API你往往不确定对方会返回哪些额外字段但你确定必须有id和status。interface Identifiable { id: string; } type EntityT T Identifiable; function processEntityT extends object(entity: EntityT) { console.log(entity.id); // 这里能访问 id因为交叉进去了一定存在 return entity; } const result processEntity({ id: abc, name: test, extra: 123 });这个函数接受任意对象同时要求它带id最终返回时原类型和新字段都完整保留。泛型参数T在这里表示“未知的、额外的属性”而交叉 Identifiable给这个未知类型补充了确定的骨架。这种模式在写插件系统、中间件、数据增强函数时特别常用。我当时设计一个埋点上报 SDK 时就用到了这种写法用户传入的自定义事件数据通过泛型保留原始类型而 SDK 内部自动附加eventId、timestamp、userId等公共字段最后生成一个T CommonTrackingFields的类型返回。这样业务方的 Tab 补全和类型检查都跟实际运行时完全对齐。4.3 联合交叉与 never、unknown、any 的交互再深入一层很多人写类型工具时遇到never、unknown、any就会发懵。这几个特殊类型与联合、交叉的运算规则我这里直接列一个速查表你写类型体操时随时能翻运算结果说明T | neverTnever 是联合类型的单位元T nevernever任何类型跟 never 交叉都是 neverT | unknownunknownunknown 是联合类型的吸收元T unknownT跟 unknown 交叉不改变原类型T | anyanyany 会“吞掉”联合T anyanyany 在交叉中也是“毒药”string | astring字面量被收进更宽的类型string aa字面量与宽类型交叉收窄成字面量这个表看着简单但特别实用。我最常用到的一个是T unknown它可以在不改变类型的前提下强制把联合类型分配到交叉运算之外在某些复杂工具类型里可以避免意外分配。另一个是T never用来制造“不可能的路径”在类型级编程中相当于布尔运算的false。4.4 从联合到交叉的转换技巧UnionToIntersection如果你在网上搜过类型体操大概率见过这个经典工具类型UnionToIntersectionT。它的一种实现方式非常巧妙地利用了“函数参数位置逆变”与“交叉类型在条件类型中作为返回值”的规则type UnionToIntersectionT ( T extends any ? (arg: T) void : never ) extends (arg: infer U) void ? U : never; type Test UnionToIntersection{ a: 1 } | { b: 2 }; // 结果是 { a: 1 } { b: 2 }这段代码的推导过程很绕但结论非常有用它能把一个联合类型转换成交叉类型。什么时候需要它比如你有一个联合的 API 定义但想把它合并成一个统一的参数对象类型时UnionToIntersection就能派上用场。我不建议每个项目都塞这种“黑魔法”但看懂它背后的原理比背下来这段代码本身更重要。它让你明白infer和条件类型、联合类型、交叉类型、逆变位置这些基础概念之间是怎么协同工作的。5. 实战复盘在业务里设计一个带状态和配置的复杂类型前面讲了够多的原理接下来我拿一个真实业务中常见的场景把整个思考过程串一遍。假设我们在开发一个数据报表组件需要同时处理异步状态和用户配置。5.1 异步数据状态的联合建模第一步用可辨识联合建模异步状态。这段代码几乎是标准模板我推荐直接抄进项目里用interface AsyncIdle { status: idle; } interface AsyncLoading { status: loading; requestId: string; startTime: number; } interface AsyncSuccessT { status: success; data: T; updatedAt: Date; } interface AsyncError { status: error; error: Error; retryCount: number; } type AsyncStateT AsyncIdle | AsyncLoading | AsyncSuccessT | AsyncError;注意这里AsyncSuccessT带了泛型这样同一个AsyncState可以被不同数据类型的请求复用。状态管理代码里到处都能看到这种类型的影子——useRequest、query、redux-slice等库的设计思路本质上都是这个形态。5.2 配置合并的交叉建模第二步设计报表组件支持的配置。基本配置由组件内置用户配置由业务方传入我们需要一个合并后的最终配置类型。interface ChartBaseConfig { width: number; height: number; renderer: canvas | svg; animation: boolean; } interface ChartUserConfig { width?: number; height?: number; title?: string; dataSource?: string; } // 这里故意让 width 和 height 可选目的是与基础配置形成覆盖关系 // 我们期望用户传了就用用户的没传就用默认的这里如果直接type FinalConfig ChartBaseConfig ChartUserConfig问题就来了width的类型是number number | undefined展开后既有number又有undefined实际上联合与交叉运算结果会让width变成number | undefined含义变得模糊。更干净的做法是定义一个明确“用户配置覆盖默认配置”的合并类型type MergedConfigBase, User OmitBase, keyof User User; type FinalConfig MergedConfigChartBaseConfig, ChartUserConfig;当User里的width是可选的number | undefinedOmitBase, width | ...会去掉Base里的width再用User里的width作为最终类型。这样FinalConfig.width的类型就是你期望的number | undefined逻辑清晰、没有歧义。我在实际项目中会额外写一个mergeConfig的运行时实现和这个类型保持同步function mergeConfigBase extends object, User extends object( base: Base, user: User ): MergedConfigBase, User { return { ...base, ...user } as MergedConfigBase, User; } const config mergeConfig( { width: 800, height: 600, renderer: canvas, animation: true }, { width: 1024, title: 月度报表 } ); // config.width 的类型是 number | undefined // config.renderer 的类型仍然是 canvas | svg这里as断言虽然有点“暴力”但运行时行为就是对象展开的覆盖逻辑所以是安全的。如果你用zod这类校验库做运行时验证还能把合并后的对象再过一遍 schema保证类型和运行时双保险。5.3 把两者组装进组件的 Props最后把状态联合和配置交叉都塞进组件的Props里。一个报表组件的属性既要接收异步状态又要接收合并后的配置还可能有一些独有的回调函数interface ChartComponentPropsT { state: AsyncStateT; config: FinalConfig; onRetry?: () void; renderEmpty?: () React.ReactNode; } function ChartComponentT(props: ChartComponentPropsT) { const { state, config } props; switch (state.status) { case idle: return div等待查询/div; case loading: return div加载中请求 ID{state.requestId}/div; case success: return ChartRenderer data{state.data} config{config} /; case error: return ( div p出错了{state.error.message}/p button onClick{props.onRetry}重试/button /div ); } }在这个组件里状态分支通过联合类型得到了完美的收窄配置通过交叉类型保留了默认字段和用户字段的合并语义。整个类型体系非常稳定你在state.status success分支里绝不可能误访问state.error而config.width也明确可能是undefined提醒你在渲染时给一个兜底值。这整个设计过程就是从“业务需求”到“类型设计”的一次落地。你会发现写类型不是在写约束文本而是在建模业务规则。规则怎么走类型就怎么写。6. 常见问题与排查技巧实录最后分享一些我在实际写代码过程中踩过、并且高频出现在同事提问里的坑整理成速查表方便你直接对照排查。场景现象原因解决方案交叉类型同名属性字段类型变成 never赋值报错两个类型对同一字段类型的交集为空用Omit先剔除冲突字段再交叉或改用interface extends让冲突直接暴露联合类型没有收窄if分支里仍然无法访问具体字段switch/if没有使用可辨识字段或字段类型不是字面量联合改用可辨识联合用公共kind/status字段 switch条件类型分发不符合预期传入联合类型时结果变成了联合的每一个分支的结果T extends X ? A : B对裸泛型参数会分配执行用[T] extends [X]包裹打破分配泛型推断不出交叉后的属性processEntity(entity)内部访问id报错泛型参数没有约束extends object或者交叉位置在泛型外部明确T extends object并用T Identifiable作为参数类型配置合并后字段逻辑混乱字段类型变成了number | undefined不符合预期直接Base User造成同名属性交叉运算使用OmitBase, keyof User User表达覆盖语义空数组类型推断为never[]const arr []给联合类型赋值报错TS 根据上下文推断数组初始化为never[]明确标注类型const arr: (string | number)[] []这些坑都有一个共同特点看起来是 TS 在“找茬”实际上只是类型运算的规则没有被充分理解。尤其是“分配律”和“同名属性交叉”这两条简直是新手崩溃重灾区。我在排查这些问题时有一个挺管用的习惯把复杂的类型推导拆成小步骤一点点看中间结果。比如在 VS Code 里把鼠标悬停在类型别名上或者用临时type Tmp ...分步输出结果。不要试图一口气想明白整个链条而是像 debug 运行时代码一样把“每一步的类型快照”打出来。这个方法虽然听起来笨拙但比靠直觉猜准得多。最后再分享一个小技巧声明类型时尽量“窄进宽出”。输入参数接受联合类型但函数的返回值尽量设计成足以满足各类调用的精确类型。function getDisplayValue(value: string | number | null): string { if (value null) return ; return String(value); }这个函数返回string但输入是联合类型。函数内部通过收窄把各种输入理顺外部调用方拿到的始终是一个稳定的string。联合类型用于控制边界交叉类型用于组合结构一个管“入口”一个管“拼装”两者分工明确页面也好、SDK 也好类型自然就清晰了。说句实在话联合类型和交叉类型这两个东西单独看每一个都不难但放到真实项目里真正拉开代码质量的往往就是你有没有把它俩用对、用透。很多项目里类型写着写着就变成了any和ts-ignore的堆砌不是因为没有类型意识而是因为没有把最底层的这两个工具握在手里。把今天这些场景吃透之后你再看那些复杂的类型工具库很多代码都能看懂它为什么这么写了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价