尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

TypeScript联合类型与交叉类型实战深度解析:类型编程与避坑指南

发布时间:2026/9/24 21:21:50

资讯中心
01
ARTICLE

TypeScript联合类型与交叉类型实战深度解析:类型编程与避坑指南

TypeScript联合类型与交叉类型实战深度解析:类型编程与避坑指南
1. 先说清楚联合类型和交叉类型到底在解决什么问题TypeScript 发展到现在早就不是“给 JS 加个类型注解”这么简单了。真正把 TS 和普通带类型的语言区分开的是它的类型系统具备极强的表达能力和组合能力。而联合类型Union Type和交叉类型Intersection Type正是这套组合能力里最基础、也最容易被人忽视的两个积木。很多朋友刚接触 TS 时看到string | number觉得“哦就是或者嘛”看到A B觉得“哦就是并且嘛”。这个理解没有错但只停留在表面。实际项目里真正让代码变得优雅或者变得痛苦的往往就是你对这两个操作符理解的深度。我举个生活化的例子。你点外卖的时候商家说“这一单可以选炸鸡或者披萨”这是联合类型——两个里面挑一个。商家又说“套餐里包含一个汉堡和一杯可乐”这是交叉类型——两个属性全都拿到手。听起来很简单对吧但在 TS 的类型世界里这俩东西的组合逻辑远比生活例子复杂而且一旦用错报错信息能让你怀疑人生。这篇文章我打算从实际开发的角度出发把这俩类型彻底讲透它们各自的底层逻辑是什么实际项目里怎么用才不别扭怎么配合泛型、条件类型、映射类型做高阶类型体操以及我踩过的那些坑。内容会比较长但看完你应该能对 TS 类型系统有一个更踏实的认知。2. 底层拆解联合类型与交叉类型各自的脾气和特性2.1 联合类型别把它只当成“多种类型选一个”联合类型的写法是A | B它表示“值可以是 A 类型也可以是 B 类型”。最常见的场景是函数参数function formatId(id: string | number) { return ID: ${id.toString()}; }这种写法在业务代码里非常常见。但联合类型的本质远远不止“或者”它背后藏着两个非常重要的特性类型收窄Narrowing和可辨识联合Discriminated Union。先看类型收窄。你写id: string | number的时候TS 编译器是不知道这个值到底属于哪一个的所以你不能直接调用只在某个类型上存在的方法。比如id.toUpperCase()会直接报错因为 number 上没有这个方法。这时候你需要收窄function formatId(id: string | number) { if (typeof id string) { return id.toUpperCase(); } return id.toFixed(2); }这看起来很简单但收窄背后的规则其实非常细腻。typeof、instanceof、in、Array.isArray、自定义类型守卫这些都是常见的收窄手段。很多时候收窄不彻底就是因为你对 TS 的“可赋值性判断”和“控制流分析”理解不到位。再说可辨识联合。这是联合类型最强大的应用方式也是我强烈建议每一个前端团队在业务代码里推广的写法。它的核心思想是给联合类型里的每一个成员都加上一个相同的字面量字段作为“身份标记”。type ApiState | { status: idle } | { status: loading; startTime: number } | { status: success; data: string[] } | { status: error; message: string };有了这个可辨识的status字段TS 才能在做 switch 判断的时候把每个分支里的数据类型精确到最小范围。你在case success里可以直接访问data在case error里可以直接访问message完全不需要额外的类型断言。这就是可辨识联合的“可辨识”三个字的真正含义。没有共同的可辨识字段联合类型很多时候就只能借用in操作符或者类型守卫来收窄体验差一个档次。2.2 交叉类型“合并”这个词其实也不太准确交叉类型的写法是A B官方文档通常用“合并”“组合”来形容它。实际语义是一个值必须同时满足 A 的结构和 B 的结构。你看下面的例子type Person { name: string; age: number; }; type Employee Person { company: string; salary: number; };Employee类型的对象必须同时包含name、age、company、salary四个属性缺一个都不行。从这个角度说“并且”确实是最直白的理解。但交叉类型有一个非常反直觉的地方如果你把两个具有相同属性名但类型不同的对象类型交叉在一起结果并不是报错而是属性类型变成两者的交叉。比如type A { id: number; name: string }; type B { id: string; age: number }; type C A B;这时候C里的id是什么类型答案是number string。它既要求是 number 又要求是 string这在运行时不存在这样的值所以这个类型实际上接近于never——任何对象都无法合法赋值给C。这种场景在真实业务中很少刻意构造但如果你在写高阶函数、装饰器、或者把多个配置对象合并时很容易无意间碰到。所以交叉类型的本质不是“把两个对象捏在一起”而是对属性的类型做了一次求交集运算。对于对象类型来说因为属性集是并集所以表面上看像“合并”但对于同一个属性它的类型会被强制收缩成所有类型的交集。这一点必须刻在脑子里。2.3 两个操作符的对比一张表说清楚我把两者的关键区别放在一张表里方便你对照着看维度联合类型A | B交叉类型A B直观语义值要么是 A要么是 B值必须同时满足 A 和 B对象属性集属性集不确定取决于当前是哪个成员属性集是两者的并集相同属性名的处理保留各自定义通过收窄区分属性类型变成两者交集典型应用可辨识联合、可选值、多态入参混入Mixin、扩展配置、组合上下文和never的关系A | never等于AA never等于never和unknown的关系A | unknown等于unknownA unknown等于A这两行关于never和unknown的关系很多人可能没认真想过。联合类型里如果有never直接把never丢掉就行交叉类型里一旦掺进never整个类型直接崩塌成never。反过来交叉类型跟unknown交叉相当于啥也没干。这些看似教科书的知识在你做条件类型、类型递归的时候会频繁用到别把它们当成考试题。3. 实战优先这两种类型在真实项目里的玩法3.1 用可辨识联合替代繁琐的 if-else 分支我自己在业务里最常用的模式就是把可辨识联合和 switch 配合起来处理各种“多形态”的数据。比如一个前端项目里常见的消息推送场景后端会返回不同类型的消息每种消息的数据结构都不一样type PushMessage | { kind: text; content: string; priority: low | high } | { kind: image; url: string; thumbnailUrl: string; width: number; height: number } | { kind: link; title: string; url: string; source: string };对应的渲染层逻辑可以用一个函数把所有分支收干净function renderMessage(message: PushMessage) { switch (message.kind) { case text: return renderText(message.content, message.priority); case image: return renderImage(message.url, message.thumbnailUrl, message.width, message.height); case link: return renderLink(message.title, message.url, message.source); default: // 这里会是什么 } }注意那个 default 分支。如果你用never来“穷尽检查”那这个模式的威力才能真正显现function assertNever(value: never): never { throw new Error(Unexpected value: ${value}); } function renderMessage(message: PushMessage) { switch (message.kind) { case text: return renderText(message.content, message.priority); case image: return renderImage(message.url, message.thumbnailUrl); case link: return renderLink(message.title, message.url, message.source); default: return assertNever(message); } }当你以后增加了一种新的消息类型比如{ kind: video; ... }而忘记在renderMessage里处理它时assertNever(message)会抛出一个编译错误。这不是运行时错误是编译期就拦住你。我见过太多项目因为消息类型越来越多渲染逻辑东漏一块西漏一块最后线上才炸。可辨识联合加never穷尽检查是目前我认为前端处理多形态数据最稳的方案没有之一。3.2 交叉类型在混入模式和扩展配置中的应用交叉类型最经典的使用场景之一是混入模式。JS 本身的继承模型比较单薄很多时候你想把一个对象的能力“混合”到另一个对象上最朴素的做法是用Object.assign。在类型层面交叉类型可以直接描述这种混合结果type Timestamped { createdAt: Date; updatedAt: Date }; type SoftDelete { deletedAt: Date | null; deletedBy?: string }; type BaseEntity { id: string; } Timestamped SoftDelete;BaseEntity就是一张典型的数据库表实体结构既有主键又有审计字段还有软删除字段。你用把这些横切关注点组合起来比反复写继承要干净得多。另一个场景是“配置扩展”。比如你有一个基础的组件配置类型不同业务场景需要追加各自的专属配置type BaseConfig { timeout: number; retryCount: number; onError: (err: Error) void; }; type HttpConfig BaseConfig { method: GET | POST; headers: Recordstring, string; }; type WebSocketConfig BaseConfig { reconnect: boolean; heartbeatInterval: number; };两个配置类型各自扩展了基础字段但都保留了timeout、retryCount、onError这些公共能力。调用方只需要面向BaseConfig写通用的处理逻辑拿到具体配置时再按需访问专有字段。这种组合方式比“一个大接口包含所有字段、用可选标记”要清晰得多也能避免类型里出现大量?导致的模糊性。不过在实战中要小心一点交叉类型不会自动做“冲突检测”。如果BaseConfig里定义了timeout: number扩展类型又想定义timeout: stringTS 不会在交叉的时候立刻给你报错而是会把timeout变成number string直到你真正赋值时才抛出难以理解的错误。所以交叉类型适用于“字段互不重叠”的场景一旦有同名字段需要你自己想清楚语义是否冲突或者用 Omit 先把冲突字段摘掉再交叉。3.3 联合类型与交叉类型配合使用的经典案例实际项目中联合类型和交叉类型往往不是单独出现而是配合着用。这里有一个我在封装“分页请求参数”时经常用到的写法type Pagination | { type: cursor; cursor: string; limit: number } | { type: offset; page: number; pageSize: number }; type Sortable { sortBy?: string; sortOrder?: asc | desc; }; type ListRequest Pagination Sortable;这个ListRequest的意思是请求列表时分页方式必须二选一游标分页或偏移分页而排序字段是可选的。对于参数解析函数来说你可以先通过type字段收窄分页方式再统一读取排序字段function parseListRequest(request: ListRequest) { // 排序字段两种分页下都可以用 const { sortBy, sortOrder } request; if (request.type cursor) { // request.cursor, request.limit 可用 return buildCursorQuery(request.cursor, request.limit, sortBy, sortOrder); } // request.page, request.pageSize 可用 return buildOffsetQuery(request.page, request.pageSize, sortBy, sortOrder); }这里的核心价值在于公共能力排序通过交叉类型附加互斥的分支通过联合类型表达。两者配合既保证了灵活性又把非法组合挡在编译期之外。你不可能构造出一个既带cursor又带page的请求对象因为类型上就不允许。这种“互斥分支 公共字段”的组合在接口入参、状态机建模、组件属性设计里都能派上用场。4. 更进一步联合类型和交叉类型在类型编程里的高阶应用4.1 条件类型里的分布特性联合类型的分发如果你只是把联合类型用在函数参数上那确实够用了。但一旦你开始写条件类型Conditional Types就必须理解联合类型的一个重要特性分布式条件类型。先看一个最简单的条件类型type IsStringT T extends string ? true : false;如果你传入type A IsStringstring | number结果是什么直觉可能会告诉你false因为string | number整体并不都满足extends string。但实际上由于条件类型在遇到裸类型参数的联合类型时会“分发”成多个判断再合并结果所以IsStringstring得到trueIsStringnumber得到false合并结果就是true | false也就是boolean。这个分发特性非常有用。比如你希望提取出联合类型里所有函数类型的成员type ExtractFunctionT T extends (...args: any[]) any ? T : never; type Mixed string | (() void) | number | (() string); type OnlyFunctions ExtractFunctionMixed; // (() void) | (() string)如果没有分发特性T extends ...的 T 是整个联合类型你是没办法做到逐成员筛选的。正因为裸类型参数会触发分发条件类型才能像“过滤器”一样工作。这也是ExcludeT, U、ExtractT, U、NonNullableT这些内置工具类型能够实现的根基。但这里有个坑一旦你给类型参数包了一层比如[T] extends [string]分发就不会发生。这是规避分发的一种常见手段。当你想要“整体判断”而不是“逐成员分发”时就用方括号把类型参数包起来type IsUnionWholeT [T] extends [string] ? true : false; // IsUnionWholestring | number 结果是 false正确理解分发与否会直接影响你写出来的工具类型是否正确。我早期写类型工具时动不动就得到莫名其妙的boolean折腾半天发现就是分发的锅。4.2 映射类型与交叉类型的取舍映射类型Mapped Types通常和联合类型、交叉类型搭配使用。比如你要把联合类型里的每个成员转成带标记的包装类型type WrappedT { [K in keyof T]: { value: T[K] }; };这是一个标准的映射类型它把对象的每个属性映射成{ value: ... }结构。但如果我们想对联合类型做映射就需要用到分布特性或工具类型的帮助。比如把联合类型转成“每个成员都有 tag 字段”的联合type TaggedT extends string { tag: T }; type Actions Taggedadd | Taggedremove | Taggedupdate;这里其实没有用映射而是直接通过联合类型生成了可辨识联合。但在很多类型体操场景中你想从已有的联合类型生成新的联合类型最常用的手段之一就是“条件类型的分发 映射类型的构造”。举一个更实际的例子从User类型里提取出值为函数类型的键然后把这些键包装成方法类型。type User { id: number; name: string; getName: () string; setName: (name: string) void; }; type FunctionKeysT { [K in keyof T]: T[K] extends (...args: any[]) any ? K : never; }[keyof T]; type UserFunctionKeys FunctionKeysUser; // getName | setName注意这里的技巧先用映射类型遍历所有键对应位置放K或者never然后再用[keyof T]索引访问把它“摊平”成联合类型。never在联合类型里会被自动过滤掉因此你得到的恰好就是所有函数类型键的联合。这是我个人认为 TS 类型编程里最常用也最优雅的小技巧之一。4.3 把交叉类型和泛型结合做一个“给所有属性追加字段”的工具类型交叉类型在类型编程里另一个重要角色是“扩展已有类型”。你有时候不想改原类型定义只想在某个局部场景里给所有属性追加能力这时候可以用交叉类型配合映射类型type WithLoggingT { [K in keyof T]: T[K]; } { log: () void; }; type Config { url: string; method: GET | POST; }; type LoggableConfig WithLoggingConfig; // { url: string; method: GET | POST } { log: () void }虽然直接写成type LoggableConfig Config { log: () void }更简单但在泛型场景里你往往不确定原始类型具体长什么样用工具类型可以批量生成。比如你要给 Redux 里的每个 action 都追加一个元数据字段type WithMetaT T { meta: { dispatchedAt: number } }; type AddAction WithMeta{ type: ADD; payload: number }; type RemoveAction WithMeta{ type: REMOVE; id: string };这时候两个 action 类型都自动携带着meta.dispatchedAt字段你在中间件里就可以统一读取而不需要每个 action 都手动定义一遍。交叉类型在这里扮演的角色就像“装饰器”——只往原有结构上叠东西不改动原结构。这个定位非常清晰。5. 避坑手册我在实际项目里踩过的联合类型与交叉类型的坑5.1 命名冲突与属性覆盖问题交叉类型最容易踩的坑就是两个类型里存在同名字段但语义不同。我曾经封装一个“用户信息 登录态”的复合类型type UserInfo { id: number; name: string; }; type LoginState { id: string; // 这里 id 是 token 字符串我图省事变种命名了 token: string; }; type LoggedUser UserInfo LoginState;结果LoggedUser的id直接变成了number string我赋值的时候 TS 疯狂报错而且报错信息非常绕大概意思是“不能将类型 X 分配给类型 Y其中 Y 的 id 属性类型为 number 与 string 的交集”。排查了半天才发现是两个id撞了。这种问题的规避方式不是靠 TS而是靠命名规范。交叉的两个类型如果可能包含同名字段最好提前做好区分比如userId和tokenId或者用Omit把其中一个类型里的冲突字段摘掉type LoginStateWithoutId OmitLoginState, id; type LoggedUser UserInfo LoginStateWithoutId;操作上并不复杂关键是要有“交叉前先检查冲突字段”的意识。我后来定的规矩是交叉类型里的成员如果其中一个类型是“底层实体模型”另一个是“上层展示模型”那么实体模型的字段名拥有最高优先级另一个类型必须主动改名或摘除冲突字段。5.2 联合类型收窄失效的场景联合类型使用中另一个高频问题就是“明明判断了类型TS 还是报错”。最常见的原因是你试图通过某些并不具备可辨识性的字段收窄联合类型。比如type Result | { ok: true; data: string } | { ok: false; message: string };当你写if (result.ok)时TS 能正确收窄吗如果result上确实只有ok一个布尔字段那么if (result.ok)其实不能区分两个成员因为{ ok: true }和{ ok: false }都不满足“通过属性存在性判断分支”。但这里巧的是ok是字面量布尔类型TS 是可以识别的。真正的坑在于如果ok的类型被声明成boolean那么分支就无法区分了type Result | { ok: boolean; data: string } | { ok: boolean; message: string };这时候if (result.ok)对两个成员来说都可能是 trueTS 无法判断你处于哪个分支data和message都不能直接访问。这是我见到很多新手困惑的地方布尔字段不等于可辨识字段。真正的可辨识字段必须是字面量类型最好是字符串字面量联合。收窄失效的另一个常见原因是你在一个对象属性上做判断但这个对象本身可能是undefined或null。比如type MaybeResult Result | null; if (result.ok) { // 报错result 可能是 null }必须先处理null判断再进行字段收窄。TS 的控制流分析虽然很聪明但它是按顺序推进的你必须让每一步的判断条件逐步缩小范围。很多“收窄失效”的问题本质上是“前面的判断没把不满足条件的值排除干净”。5.3 交叉类型与联合类型混合时可读性崩坏的风险联合类型和交叉类型叠加使用时还有一个隐形坑类型可读性急剧下降。看这个例子type RequestState | ({ status: loading } { progress: number }) | ({ status: success } { data: unknown }) | ({ status: error } { message: string });虽然它表达的是三种状态但括号里的交叉让整个类型的可读性变得非常差。尤其是当团队成员不熟悉交叉类型的语义时看到可能还会误以为是“同时满足两种状态”。在可辨识联合里我建议尽量保持每个分支的类型是单一对象字面量而不是交叉组合。如果需要公共字段可以提取成接口再合并type RequestBase { requestId: string }; type RequestState | (RequestBase { status: loading; progress: number }) | (RequestBase { status: success; data: unknown }) | (RequestBase { status: error; message: string });这样requestId在三个分支里都能直接访问但每个分支的可辨识字段依然清晰。把交叉类型用于“公共字段抽取”而不是用于“叠加复杂形态”这是我总结了无数次的教训后定下的规矩。5.4 快速排查清单我把自己平时排查类型问题时的思路整理成一个清单你可以直接拿来用症状怀疑方向排查动作属性类型变成奇怪的交集同名字段冲突用Omit摘除冲突字段或改名可辨识联合收窄不生效可辨识字段不是字面量类型检查字段类型改成字符串字面量联合条件类型结果变成boolean裸类型参数引发分发用[T]包裹阻止分发交叉后整个类型变成never某个成员类型包含never检查成员里是否有never或互斥字面量联合类型访问公共属性报错成员之间没有公共属性提取公共接口或者增加可辨识字段这个清单不是什么高深理论就是我这些年排查类型问题时的肌肉记忆。你遇到 TS 报错时先别急着as any照着清单查一遍通常能定位到根因。6. 写在最后的实操体会跟联合类型和交叉类型打了这么多年交道我的总体感受是它们不难但很容易被低估。很多人学了基础语法就上手写业务结果遇到类型报错就as any一把梭等到类型真正变成维护负担时才开始后悔。我自己的建议是小项目可以随便用但一旦项目进入多人协作阶段最好在团队里定几条类型规范。比如可辨识联合的kind字段必须用字面量联合、交叉类型禁止重名字段、条件类型统一用方括号控制分发等。这些规矩看起来是约束实际上是保护——它们能让 TS 的类型推导始终在你的控制范围内而不是时不时给你一个看不懂的报错。另外说句题外话很多人喜欢追求复杂的类型体操觉得写出一串infer、keyof、extends很酷。但我个人觉得类型系统的终极目标是让正确的事情变得容易让错误的事情在编译期就被拦截。如果你写的类型别人看不懂或者要把十分钟才能讲明白那它在工程上的价值就要打个问号。最后分享一个实用的小技巧当你在 IDE 里看到一个类型被解析成非常复杂的形式想知道它具体长什么样时可以直接把鼠标悬停在类型别名上或者用type ExpandT { [K in keyof T]: T[K] }这个工具类型把交叉类型“展开”成扁平结构。这个方法在排查交叉类型问题时格外好用比我一开始用各种断言瞎试要高效得多。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。