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等于A | A & never等于never |
和unknown的关系 | A | unknown等于unknown | A & 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: Record<string, string>; }; type WebSocketConfig = BaseConfig & { reconnect: boolean; heartbeatInterval: number; };两个配置类型各自扩展了基础字段,但都保留了timeout、retryCount、onError这些公共能力。调用方只需要面向BaseConfig写通用的处理逻辑,拿到具体配置时再按需访问专有字段。这种组合方式比“一个大接口包含所有字段、用可选标记”要清晰得多,也能避免类型里出现大量?导致的模糊性。
不过在实战中要小心一点:交叉类型不会自动做“冲突检测”。如果BaseConfig里定义了timeout: number,扩展类型又想定义timeout: string,TS 不会在交叉的时候立刻给你报错,而是会把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 IsString<T> = T extends string ? true : false;如果你传入type A = IsString<string | number>,结果是什么?直觉可能会告诉你false,因为string | number整体并不都满足extends string。但实际上,由于条件类型在遇到裸类型参数的联合类型时,会“分发”成多个判断再合并结果,所以:
IsString<string>得到true;IsString<number>得到false;- 合并结果就是
true | false,也就是boolean。
这个分发特性非常有用。比如你希望提取出联合类型里所有函数类型的成员:
type ExtractFunction<T> = T extends (...args: any[]) => any ? T : never; type Mixed = string | (() => void) | number | (() => string); type OnlyFunctions = ExtractFunction<Mixed>; // (() => void) | (() => string)如果没有分发特性,T extends ...的 T 是整个联合类型,你是没办法做到逐成员筛选的。正因为裸类型参数会触发分发,条件类型才能像“过滤器”一样工作。这也是Exclude<T, U>、Extract<T, U>、NonNullable<T>这些内置工具类型能够实现的根基。
但这里有个坑:一旦你给类型参数包了一层,比如[T] extends [string],分发就不会发生。这是规避分发的一种常见手段。当你想要“整体判断”而不是“逐成员分发”时,就用方括号把类型参数包起来:
type IsUnionWhole<T> = [T] extends [string] ? true : false; // IsUnionWhole<string | number> 结果是 false正确理解分发与否,会直接影响你写出来的工具类型是否正确。我早期写类型工具时,动不动就得到莫名其妙的boolean,折腾半天发现就是分发的锅。
4.2 映射类型与交叉类型的取舍
映射类型(Mapped Types)通常和联合类型、交叉类型搭配使用。比如你要把联合类型里的每个成员转成带标记的包装类型:
type Wrapped<T> = { [K in keyof T]: { value: T[K] }; };这是一个标准的映射类型,它把对象的每个属性映射成{ value: ... }结构。但如果我们想对联合类型做映射,就需要用到分布特性或工具类型的帮助。比如把联合类型转成“每个成员都有 tag 字段”的联合:
type Tagged<T extends string> = { tag: T }; type Actions = Tagged<"add"> | Tagged<"remove"> | Tagged<"update">;这里其实没有用映射,而是直接通过联合类型生成了可辨识联合。但在很多类型体操场景中,你想从已有的联合类型生成新的联合类型,最常用的手段之一就是“条件类型的分发 + 映射类型的构造”。
举一个更实际的例子:从User类型里提取出值为函数类型的键,然后把这些键包装成方法类型。
type User = { id: number; name: string; getName: () => string; setName: (name: string) => void; }; type FunctionKeys<T> = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T]; type UserFunctionKeys = FunctionKeys<User>; // "getName" | "setName"注意这里的技巧:先用映射类型遍历所有键,对应位置放K或者never,然后再用[keyof T]索引访问把它“摊平”成联合类型。never在联合类型里会被自动过滤掉,因此你得到的恰好就是所有函数类型键的联合。这是我个人认为 TS 类型编程里最常用也最优雅的小技巧之一。
4.3 把交叉类型和泛型结合,做一个“给所有属性追加字段”的工具类型
交叉类型在类型编程里另一个重要角色,是“扩展已有类型”。你有时候不想改原类型定义,只想在某个局部场景里给所有属性追加能力,这时候可以用交叉类型配合映射类型:
type WithLogging<T> = { [K in keyof T]: T[K]; } & { log: () => void; }; type Config = { url: string; method: "GET" | "POST"; }; type LoggableConfig = WithLogging<Config>; // { url: string; method: "GET" | "POST" } & { log: () => void }虽然直接写成type LoggableConfig = Config & { log: () => void }更简单,但在泛型场景里,你往往不确定原始类型具体长什么样,用工具类型可以批量生成。比如你要给 Redux 里的每个 action 都追加一个元数据字段:
type WithMeta<T> = 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 = Omit<LoginState, "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)对两个成员来说都可能是 true,TS 无法判断你处于哪个分支,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 Expand<T> = { [K in keyof T]: T[K] }这个工具类型把交叉类型“展开”成扁平结构。这个方法在排查交叉类型问题时格外好用,比我一开始用各种断言瞎试要高效得多。