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()
}

使用联合值时,只能直接访问所有成员都支持的能力。通过 typeofin、相等性判断或自定义类型守卫收窄后,才能使用某个分支特有的成员。

对象联合最实用的形式是可辨识联合:每个成员共享一个字面量判别字段。

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 数据。

官方资料

系列导航:目录 · 上一篇 · 下一篇