TypeScript联合类型与类型别名:类型收窄与工程实践

发布时间:2026/10/9 5:30:29
TypeScript联合类型与类型别名:类型收窄与工程实践
做前端或者写过一阵TypeScript的同学对联合类型和类型别名这两个词一定不陌生。网上讲基础语法的文章一抓一大把什么string | number、type User { ... }看一遍就会了。但真到了项目里我发现很多人用起来还是发虚——什么时候该用联合类型什么时候该定义类型别名联合类型配合类型收窄到底怎么玩才能写出又稳又好看的代码这些问题很少有人讲透。这篇就把这几个事儿掰开揉碎了说清楚顺便把我这几年在项目里踩过的坑、总结出来的套路一并交代了。1. 联合类型不只是“或”那么简单1.1 联合类型的本质是什么联合类型Union Type在TypeScript里的写法就是用|把若干个类型串起来表示“值可能是这些类型中的任意一个”。比如string | number意思就是这个值要么是字符串要么是数字。很多初学者容易把联合类型理解成“类型的选择题”这种理解没错但太浅了。更深一层看联合类型其实是TypeScript类型系统对“不确定但有限”的一种精准表达。它把可能性圈定在一个明确的集合里编译器在这个集合内帮你做检查。超出集合的值连编译这关都过不了。举个最简单的例子type ID string | number; function printID(id: ID) { console.log(id.toUpperCase()); // 报错类型“string | number”上不存在属性“toUpperCase” }这段代码会报错因为ID可能是个数字数字没有toUpperCase方法。TypeScript不做无谓的假设这是好事——它逼着你想清楚运行时的真实情况。你应该先收窄类型function printID(id: ID) { if (typeof id string) { console.log(id.toUpperCase()); } else { console.log(id.toFixed(2)); } }一旦做了typeof判断TypeScript在对应的分支里就能精确推断出string或number这就是类型收窄Narrowing。这个概念后面还会反复用到它是联合类型真正威力得以发挥的开关。1.2 字面量联合类型枚举的平替与增强联合类型最常见的应用场景之一是用字符串字面量定义一组可选的固定值。比如接口返回的订单状态type OrderStatus pending | paid | shipped | completed | cancelled; function handleOrderStatus(status: OrderStatus) { switch (status) { case pending: // ... break; case paid: // ... break; // ... } }这种做法在工程里我非常推荐。对比传统的enum字面量联合类型有几个实打实的好处编译后不会产生额外代码enum编译后会留下一个反向映射对象白白增加产物体积用enum时你可以随便写一个不在枚举里的数字赋值给枚举类型的变量数值枚举的老毛病字面量联合类型则完全堵死了这个口子类型是直接用字符串表达的鼠标悬停上去就能看到全部可能值真就是所见即所得。当然我不是说enum一无是处。如果这个状态需要反向映射比如根据值取名字或者有复杂的常量行为enum依然有其用武之地。但在绝大多数业务场景下字面量联合类型是更轻、更安全、更直观的选择。1.3 可空类型的表达联合类型还有一个非常高频的用途——表达“可能没有值”的情况。很多从Java、C#转过来的同学习惯用null表示“没有”但TypeScript里更推荐把null或undefined显式写进联合类型type MaybeString string | null | undefined; function formatName(name: MaybeString) { // 这里TS会强制你先处理空值 if (name null) { return 未知用户; } return name.trim(); }注意name null这里的双等号是刻意为之的它同时判断了null和undefined两种情况。在类型收窄的语境下这是一种非常简练的技巧。写到这里联合类型的基础就铺好了。接下来看类型别名——这个看起来更简单的工具其实藏着不少值得深挖的设计决策。2. 类型别名给复杂类型一个名字2.1 类型别名的基本语法与应用场景类型别名Type Alias用type关键字定义本质上就是给一个类型起一个新名字。乍一听很简单但它的价值取决于你用它包装的是什么类型。type UserID string; type Callback (err: Error | null, data?: Response) void; type Point { x: number; y: number };三个例子分别是简单类型的别名、函数类型的别名、对象类型的别名。实际项目中函数类型和对象类型的别名是最有价值的因为这两类类型写起来冗长、容易出错取个名字之后整个代码的可读性会明显上一个台阶。我最常用类型别名的场景之一是把后端接口的返回结构统一收敛。比如type APIResponseT { code: number; message: string; data: T; traceId: string; }; type UserInfo APIResponse{ id: string; name: string; avatar: string; };这样请求函数的返回值写起来就非常清爽async function fetchUserInfo(id: string): PromiseUserInfo { const res await fetch(/api/user/${id}); return res.json(); }2.2 类型别名与接口到底选哪个这是TypeScript社区争论多年的老话题也是面试高频题。我的建议非常明确能用interface的对象类型优先用interface其余场景用type。理由很简单interface支持声明合并declaration merging这在扩展第三方库类型时几乎是必备能力interface在编辑器里的报错信息通常更友好性能也更好type能做interface做不了的事联合类型、交叉类型、条件类型、映射类型、工具类型派生等。但反过来说一旦需要表达的不是一个“对象形状”而是“几种类型的组合”interface就无能为力了这时候必须用type。比如之前说的OrderStatus字面量联合类型interface完全写不了。所以我的选择法则是描述对象的结构、类实现的契约 —— 用interface需要联合类型、交叉类型、函数类型、工具类型派生 —— 用type拿不准的时候优先interface实在不行再换type。2.3 类型别名的复用与组合类型别名的复用能力非常强它可以通过交叉类型实现类型组合type BaseEntity { id: string; createdAt: string; updatedAt: string; }; type Product BaseEntity { name: string; price: number; stock: number; }; type User BaseEntity { username: string; email: string; };Product和User都复用了BaseEntity里的公共字段避免了重复定义后续如果要在所有实体上加一个version字段只需要改BaseEntity一处即可。这种组合方式在大型项目里非常实用。注意交叉类型和interface extends在语义上有一点微妙差异。extends强调的是“继承”子接口可以覆盖父接口的同名属性有兼容性限制而是把两边强制合并同名冲突时属性类型会被求成交集而不是报错。用时如果两个类型有同名属性但类型不一致结果可能会出乎意料所以组合前要确认好字段没有冲突。3. 高级玩法联合类型与类型别名的组合实战3.1 可辨识联合Discriminated Union可辨识联合是联合类型在真实业务中最高价值的用法没有之一。它的核心思想是给联合类型里的每个成员一个独特的字面量字段通常叫type或kindTypeScript就能在收窄时精确地识别出当前到底是哪个成员让代码走到正确的分支。来看一个实际例子。一个内容平台有文章、图片、视频三种内容类型每种类型的元数据结构完全不同type ArticleContent { kind: article; title: string; body: string; readingTime: number; }; type ImageContent { kind: image; url: string; width: number; height: number; altText: string; }; type VideoContent { kind: video; url: string; duration: number; thumbnail: string; }; type Content ArticleContent | ImageContent | VideoContent;处理这组数据的时候你不需要任何类型断言不需要as any只凭一个switch就能让TypeScript在每个分支里为你提供完整准确的类型信息和补全function renderDuration(content: Content): string { switch (content.kind) { case article: return ${content.readingTime} 分钟阅读; case image: return ${content.width} x ${content.height}; case video: return ${Math.round(content.duration / 60)} 分钟视频; } }在case article分支里content被自动收窄为ArticleContent你能用content.readingTime在case video分支里你又可以用content.duration。这些都是在编译期由类型系统保障的不需要运行时的任何额外判断。可辨识联合之所以强大是因为它把“类型判断”这件事从运行时的责任转移到了编译时。原来你可能要用if (item.type article item.readingTime)这种既丑又容易漏判的代码现在类型系统替你兜底。漏掉一个分支编译器就会提示你“函数缺少结束返回语句”。3.2 类型收窄的四种常用姿势可辨识联合用的是switch收窄但实际开发里收窄的类型远不止这一种。我把自己常用的收窄方式汇总一下typeof 收窄适用于string、number、boolean、symbol、bigint等基础类型。in 操作符收窄适用于判断“某个属性是否存在于对象中”常用于没有可辨识字段的联合类型。instanceof 收窄适用于Date、RegExp、Error等内建类或自定义类实例的区分。自定义类型谓词type predicate当内置收窄规则不够用时自己写收窄函数返回值的类型标注为arg is Type。类型谓词这个稍微有点绕看个例子type Fish { swim: () void }; type Bird { fly: () void }; function isFish(animal: Fish | Bird): animal is Fish { return (animal as Fish).swim ! undefined; }之后你写if (isFish(animal))TypeScript在真分支里就能把animal当作Fish用。这个技巧在处理复杂联合类型时非常有用尤其是从后端拿回来的数据形态混乱时。3.3 模板字面量类型与联合类型的碰撞模板字面量类型是TypeScript 4.1引入的能力它和联合类型搭配起来能做出很多精巧的类型。比如定义一组以特定前缀开始的字符串type EventName on${Click | Focus | Blur}; // 等价于 onClick | onFocus | onBlur再比如后端接口的版本号或者权限码type Permission view:${user | order | product} | edit:${user | order | product};这样定义之后权限字符串拼错一个字母都过不了编译。你可能会觉得这有点“炫技”但在我维护的一些权限管理项目里这个做法确实帮我挡掉了好几回手滑写错的权限码。3.4 条件类型下的联合类型分发机制聊到联合类型不聊条件类型的分发机制总觉得不完整。简单说当联合类型直接裸在条件类型里时TypeScript会把联合类型的每个成员分别代入条件判断最后把结果再组合成联合类型type NullableT T | null; type StringOrNull Nullablestring; // string | null type ArrayifyT T extends any ? T[] : never; type NumberArrayOrStringArray Arrayifynumber | string; // number[] | string[]这个分发特性是很多高级工具类型比如Exclude、Extract、NonNullable的实现基础理解了它你就能看懂很多类型工具库的源码。乍一看可能觉得绕但用多了你会发现它像条件表达式一样自然。4. 工程实践那些年我们踩过的坑4.1 联合类型不等于交叉类型的“覆盖”很多同学在写复杂类型时会试图用交叉类型给联合类型“加字段”type A { name: string }; type B A { age: number };这合情合理B既有name又有age。但如果两个类型有同名但不同类型的字段交叉类型会自动求交集结果可能让你懵type X { id: string }; type Y { id: number }; type Z X Y; // id 的类型是 string number也就是 neverid在Z里是never意味着你根本没法给这个字段赋任何值。这种坑很隐蔽因为编译不一定会直接报错等到赋值的时候才发现完全用不了。所以用交叉类型组合两个对象类型前先确认一下字段是不是有冲突。4.2 联合类型配可空别漏空值判断从后端接口拿数据时最简单也最容易出错的地方就是可空字段。比如一个接口的返回值是type UserInfo { name: string; address?: { city: string; street: string; }; };address是可选的也就是UserInfo.address的类型是{ city: string; street: string } | undefined。如果直接写user.address.city运行时就会炸出Cannot read properties of undefined。这种错误在使用JavaScript时只能靠运行时试错发现但TypeScript会在编译时就拦住你。链式判断配合可选链可以优雅地解决这个问题const city user.address?.city ?? 未知城市;这里?.处理了undefined??处理了空值兜底。TypeScript的类型系统会促使你把这层防御做对而不是让你侥幸绕过。这也是我推荐大家尽量别用any的原因——any会把这层安全网直接撕掉。4.3 别滥用as优先用收窄和守卫老实说我见过不少同学一旦遇到类型不匹配就上asconst status res.status as OrderStatus;这种做法在少数情况可以接受但如果res.status其实来自某个不可控的数据源as就是在骗类型系统跑起来照样可能出问题。推荐的做法是先做一个运行时校验函数把类型守卫用起来const ORDER_STATUSES [pending, paid, shipped, completed, cancelled] as const; function isOrderStatus(value: unknown): value is OrderStatus { return typeof value string (ORDER_STATUSES as readonly string[]).includes(value); } if (isOrderStatus(res.status)) { // 到这里 res.status 才是 OrderStatus真正安全了 }这个模式我称为“运行时收窄”它结合了编译期类型和运行时校验的优点。在对后端数据做解析的场景里这种写法能帮你把大部分脏数据问题挡在源头。4.4 泛型内联写法与类型别名的取舍代码里经常能看到这样的写法function getFirstT(arr: T[]): T | undefined { return arr[0]; }泛型内联在函数定义时是必要的但如果你发现同一个泛型类型在多处重复出现就应该提炼成类型别名type PaginatedT { list: T[]; total: number; page: number; pageSize: number; };否则每次写{ list: T[]; total: number; page: number; pageSize: number; }都会很痛苦而且一旦接口结构变了你会面临到处改的灾难。类型别名其实就是把重复和不确定性收敛到一个地方的机制和函数提取重复逻辑是一个道理。5. 实用工具类型与联合类型的结合5.1 常用工具类型速查TypeScript内置的工具类型很多都是基于联合类型实现的这几个我用得最频繁工具类型作用示例PartialT把T所有属性变为可选PartialUserInfoRequiredT把T所有属性变为必选RequiredConfigPickT, K从T中挑出K对应的属性PickUserInfo, name | ageOmitT, K从T中剔除K对应的属性OmitUserInfo, passwordExcludeT, U从联合类型T中排除UExcludeOrderStatus, cancelledExtractT, U取T和U的交集Extractstring | number, numberNonNullableT移除T的null和undefinedNonNullableMaybeStringExclude和Extract是我日常处理联合类型时最喜欢的两个工具。比如订单状态里要排除掉“已取消”的状态用于展示可操作列表一行搞定type ActiveOrderStatus ExcludeOrderStatus, cancelled;5.2 映射类型与联合类型的联动映射类型Mapped Type配合keyof也是联合类型的经典使用场景本质上是把一个对象的所有键组成联合类型然后逐个映射成新的类型type FeatureFlags { darkMode: boolean; beta: boolean; newDashboard: boolean; }; type FlagKeys keyof FeatureFlags; // darkMode | beta | newDashboard配合Record工具类型可以快速构造一批同构类型type AllowedPages home | profile | settings; type RouteConfig RecordAllowedPages, { title: string; icon: string };这在配置管理、路由表、权限表这些场景里非常省事。Record会在编译期确保你覆盖了每一个联合类型成员少配一个页面就会报错。6. 真实项目复盘一次联合类型重构带来的收益讲一个我实际经历过的案例。两年前接手一个中后台管理系统里面有个状态字段在十几个组件里被反复使用当时的代码长这样// 到处散布着魔法字符串 if (status 1) { ... } if (status 2) { ... }看到“1”、“2”这种魔法数字/字符串我整个人都不好了。于是第一步先全仓搜出所有使用点确认状态的实际取值然后定义类型别名type AuditStatus pending | approved | rejected;接着把后端返回的字段统一做一次运行时收窄转换type AuditResult { status: unknown }; function toAuditStatus(raw: unknown): AuditStatus { if (raw pending || raw approved || raw rejected) { return raw; } return rejected; // 默认拒绝安全兜底 }重构完成后所有组件里都直接用类型安全的AuditStatus不再有魔法字符串。让我印象最深的是重构过程中TypeScript直接帮我找到了两处之前被忽略的分支那两处在运行时永远走不到正确的逻辑。如果靠人工review这种bug几乎不可能被发现但类型系统在编译期就帮你排查清楚了。这个案例让我更加确信联合类型和类型别名不只是语言特性它们是工程规范的一部分。用好了它们会让你的代码从一开始就朝着正确的方向生长。7. 几个值得长期记住的习惯结合我这几年的实践最后给几个实用的习惯总结能用字面量联合类型表达的状态就不要用魔法数字或魔法字符串。码农的一生都在和这种隐性契约作斗争类型定义是你免费的文档还能被编译器检查和验证。可辨识联合是处理复杂数据结构的首选方案。判断条件不要靠“有没有某个字段”而是靠一个显式的kind/type字段。这样无论数据怎么变分支逻辑都清晰可循。类型别名是消除重复的最佳工具。一个复杂类型如果在两个以上地方出现就该抽出来取名字。这不是过度设计这是降低认知负担。收窄优先级高于断言。能用typeof、in、instanceof、类型守卫收窄就绝不写as。as是对类型系统的让步而类型守卫是站在类型系统这边的合作。另外分享一个小技巧在编辑器里给类型别名和联合类型写清晰的名字比如加Type、Status、Info这样的后缀鼠标悬停时能一眼看出这是什么。命名这个东西短期看无关紧要长期看就是代码可维护性的分水岭。联合类型和类型别名这两个概念乍一看只是TypeScript的基础语法但真正把它们用进骨子里你会发现整个代码的健壮性、可读性、可维护性都会产生质变。希望这篇文章能帮你把这些工具内化成自己的武器让代码少一些“运行时才知道”的意外多一些“编译期就搞定”的安心。