TypeScript 类型组合:联合、交叉、type、interface 与 enum
TypeScript 类型组合:联合、交叉、type、interface 与 enum
系列导航:TypeScript 7 现代开发指南
上一篇:对象建模
下一篇:类与契约
真实业务类型很少彼此孤立:状态由几个固定值组成,事件有多种形态,对象需要复用基础字段,常量还可能同时服务于类型检查和运行时代码。TypeScript 7.0.2 提供字面量、联合、交叉、类型别名、接口和枚举等工具。它们不是互相取代的语法,而是在回答不同问题:值可以是哪一种、对象必须同时具备什么、契约是否需要开放扩展,以及运行时是否需要一个真实对象。
一、字面量类型与类型拓宽
字面量类型把值本身当作类型:
type Theme = 'light' | 'dark'
type RetryCount = 0 | 1 | 2 | 3
type Enabled = true
const 变量通常会保留原始值的字面量类型,而 let 变量需要为后续赋值留出空间,因此通常会拓宽:
const fixedMode = 'production' // 类型为 'production'
let currentMode = 'production' // 类型通常为 string
currentMode = 'development'
对象属性默认仍被认为可能修改,所以即使对象变量使用 const,属性也可能被拓宽:
const config = {
mode: 'production', // 通常推断为 string
retry: 3, // 通常推断为 number
}
如果这组数据就是不会变化的常量表,可使用 as const:
const config = {
mode: 'production',
retry: 3,
} as const
// mode 为 'production',retry 为 3,属性同时变为 readonly
as const 适合保留整段字面量表达式的具体值,不应拿来替代业务校验。需要同时验证形状时,可以配合 satisfies:
const routes = {
home: '/',
settings: '/settings',
} as const satisfies Record<string, `/${string}`>
二、联合类型:值可以是多种形态之一
联合类型使用 | 表示“或”:
type Id = string | number
function normalizeId(id: Id): string {
return typeof id === 'number' ? String(id) : id.trim()
}
使用联合值时,只能直接访问所有成员都支持的能力。通过 typeof、in、相等性判断或自定义类型守卫收窄后,才能使用某个分支特有的成员。
对象联合最实用的形式是可辨识联合:每个成员共享一个字面量判别字段。
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; message: string }
function stateText(state: RequestState): string {
switch (state.status) {
case 'idle':
return '尚未请求'
case 'loading':
return '加载中'
case 'success':
return `共 ${state.data.length} 条`
case 'error':
return state.message
}
}
相比把 data?、message? 全部堆在一个接口里,可辨识联合能排除“成功却没有数据”“空闲却带错误消息”等非法组合。需要在分支增加时获得编译提醒,还可以结合 never 做穷尽检查。
三、交叉类型:同时满足多个契约
交叉类型使用 & 表示“并且”,常用于组合对象能力:
type Timestamped = {
createdAt: Date
updatedAt: Date
}
type Article = {
id: string
title: string
}
type StoredArticle = Article & Timestamped
const article: StoredArticle = {
id: 'a_001',
title: '类型组合',
createdAt: new Date(),
updatedAt: new Date(),
}
交叉发生在类型层面,不会在运行时自动合并两个对象。创建值时仍需要实际提供全部成员,或者显式执行对象合并:
const stored = {
...article,
updatedAt: new Date(),
}
相同属性的类型如果互不兼容,交叉结果可能无法构造:
type StringId = { id: string }
type NumberId = { id: number }
type Impossible = StringId & NumberId
// Impossible['id'] 是 never
因此,交叉不是解决字段冲突的覆盖操作。组合前应确认同名成员兼容,或使用 Omit 等工具先明确移除旧字段。
四、类型别名:为任意类型命名
type 可以给原始类型、联合、元组、函数、对象和类型运算结果命名:
type UserId = string
type Coordinates = readonly [x: number, y: number]
type SaveHandler = (content: string) => Promise<void>
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: string }
类型别名不会创建新的运行时值,也不会天然创建名义类型。type UserId = string 仍然与普通 string 兼容。
类型别名当然可以复用和扩展已有对象类型,通常通过交叉类型完成:
type Named = {
name: string
}
type Employee = Named & {
employeeId: string
}
因此,不要把差异概括为“type 不能扩展”。更准确的说法是:interface 使用 extends 声明对象契约的继承,类型别名通常使用 & 组合类型;此外,条件类型和泛型约束中也会使用 extends 关键字。
五、接口:可继承、可开放合并的对象契约
interface 主要描述对象、函数和类实例的结构:
interface Named {
name: string
}
interface User extends Named {
id: string
login(): Promise<void>
}
接口可以继承接口,也可以继承成员静态已知的对象类型别名:
type Identified = {
id: string
}
interface Admin extends Identified {
permissions: string[]
}
反过来,类型别名也能与接口组合:
type AuditedAdmin = Admin & {
updatedAt: Date
}
接口最独特的能力是声明合并。同一作用域内的同名接口声明会合为一个契约:
interface PluginContext {
appName: string
}
interface PluginContext {
log(message: string): void
}
const context: PluginContext = {
appName: 'Blog',
log(message) {
console.log(message)
},
}
声明合并常用于扩展全局对象或第三方库类型,但在普通业务代码中也可能因重名而意外扩大契约。类型别名不能用重复声明来合并;需要扩展时应创建一个新名称并显式组合。
六、type 还是 interface
两者都能描述普通对象,大多数时候团队一致性比语法偏好更重要。可以按能力选择:
| 需求 | 更自然的选择 |
|---|---|
| 联合、元组、函数组合、条件类型、映射类型 | type |
面向对象公开契约,希望使用 extends |
interface |
| 需要声明合并或第三方模块扩展 | interface |
| 从多个已有类型组合出新对象 | type + &,或 interface extends |
| 普通封闭业务对象 | 两者都可以,遵循项目约定 |
接口的报错和编辑器展示有时更接近原始声明,类型别名则更擅长表达类型运算。这不是绝对的性能或能力高下,也没有必要为了统一而把所有类型机械改成同一种写法。
七、enum 同时存在于类型和运行时
普通 enum 与多数 TypeScript 类型不同:它通常会生成一个真实的 JavaScript 对象。
enum LogLevel {
Info = 'info',
Warning = 'warning',
Error = 'error',
}
function writeLog(level: LogLevel, message: string) {
console.log(level, message)
}
writeLog(LogLevel.Info, '应用启动')
console.log(Object.values(LogLevel))
这个运行时对象在以下场景有实际价值:
- 现有库、生成代码或
.d.ts已经把参数声明为某个枚举。 - 需要在运行时枚举成员,且希望类型和值共用同一命名空间。
- 需要与明确采用数字或字符串枚举的协议、SDK 互操作。
代价是编译产物中会多出枚举初始化代码。数字枚举还会生成反向映射,运行时对象比字符串枚举更复杂。这个开销通常不大,但它说明 enum 不是纯类型别名;是否采用应由运行时和互操作需求决定。
const enum 可以把成员引用内联,但跨包发布、版本错配、声明文件消费以及 isolatedModules 工具链可能带来限制。应用内部若没有统一编译边界,不要只为少量体积收益贸然使用它。
八、应用常量优先考虑 as const 对象
很多前端应用只需要一组可导入、可遍历的运行时常量,以及由这些值生成的联合类型。此时 as const 对象更直接:
export const OrderStatus = {
Pending: 'pending',
Paid: 'paid',
Cancelled: 'cancelled',
} as const
export type OrderStatus =
(typeof OrderStatus)[keyof typeof OrderStatus]
function updateStatus(status: OrderStatus) {
console.log(status)
}
updateStatus(OrderStatus.Paid)
它生成的 JavaScript 就是普通对象,容易与 JavaScript 项目、JSON 值和构建工具互操作。如果只需要类型、不需要运行时容器,直接使用字面量联合更简单:
type OrderStatus = 'pending' | 'paid' | 'cancelled'
推荐 as const 对象不等于“永远禁用枚举”。面对已有枚举 API 时继续使用对应 enum 最清晰;应用自身的新常量则优先从普通 JavaScript 数据结构出发。
九、实战:组合一个类型安全的动作模型
下面用 as const 对象提供运行时动作名,用可辨识联合描述不同负载:
const ActionType = {
Add: 'todo/add',
Toggle: 'todo/toggle',
} as const
type Action =
| {
type: typeof ActionType.Add
payload: { text: string }
}
| {
type: typeof ActionType.Toggle
payload: { id: string }
}
interface Todo {
id: string
text: string
done: boolean
}
type TodoState = {
items: Todo[]
} & {
updatedAt: Date
}
function reduce(state: TodoState, action: Action): TodoState {
switch (action.type) {
case ActionType.Add:
return {
items: [
...state.items,
{ id: crypto.randomUUID(), text: action.payload.text, done: false },
],
updatedAt: new Date(),
}
case ActionType.Toggle:
return {
items: state.items.map((todo) =>
todo.id === action.payload.id
? { ...todo, done: !todo.done }
: todo,
),
updatedAt: new Date(),
}
}
}
动作名在运行时可用,Action 则保证每种动作携带正确负载。TodoState 展示了交叉类型的组合能力,但若状态结构本来就只在一个位置定义,直接写成单个接口也完全合理。
十、常见陷阱
1. 误以为联合值拥有所有成员的属性
联合表示“当前是其中一种”,不是“同时是全部”。先根据判别字段或运行时检查收窄。
2. 把交叉类型当成对象覆盖
A & B 不执行对象展开,也不会让后一个同名属性覆盖前一个。冲突属性可能变成 never。
3. 宣称 type 不能扩展
类型别名可以通过交叉类型复用和扩展,接口也可以继承合适的对象类型别名。真正的差异是组合语法和声明合并能力。
4. 无意触发接口声明合并
公共接口扩展很有用,业务作用域中的重复命名却可能隐藏错误。对不希望开放的模型使用唯一名称,并让模块边界清晰。
5. 为所有常量默认使用 enum
普通枚举会进入运行时产物。应用级字符串常量通常可用字面量联合或 as const 对象;已有枚举协议和需要运行时枚举对象的场景仍适合 enum。
6. 用 as const 假装验证了外部数据
as const 只改变编译器对字面量表达式的推断,不会验证网络响应,也不会运行时冻结深层对象。
小结
字面量类型描述精确值,联合表达多选一,交叉表达同时满足;type 能为任意类型运算命名,interface 擅长可继承、可合并的对象契约。两者都能扩展已有结构,只是语法与开放性不同。普通 enum 有真实运行时对象,适合互操作和确实需要枚举值对象的场景;多数应用内部常量可以优先使用字面量联合或 as const 对象,让类型自然来源于普通 JavaScript 数据。
官方资料
- Everyday Types:Literal Types 与 Unions
- Narrowing:Discriminated Unions
- Object Types:Intersection Types 与 Interfaces
- Enums
- Declaration Merging