够用的 TypeScript 实践指南:从类型体操到工程落地

TypeScript 在前端开发中已经不是"要不要用"的问题,而是"怎么用好"的问题。社区不缺入门教程,这里不再重复基础概念,而是聚焦那些实际写代码时容易踩坑的地方——类型兼容性的微妙差异、协变逆变的实际影响、泛型的正确打开方式,以及工程实践中的设计原则。

类型系统中的细节陷阱

常量类型与 Template Literal Types

基础类型不止 string | number | boolean,每个基础类型下的值都可以被声明为更具体的常量类型

type HelloWorld = "Hello, world!"
const greeting: HelloWorld = "Hello, world!"
 
const commonString: string = greeting       // ✅ 子类型可赋值给上层
const nextGreeting: HelloWorld = commonString // ❌ 上层不能赋值给子类型

TS 4.1 引入的 Template Literal Types 更是让字符串类型有了强大的表现力:

type Major = string
type Minor = string
type Patch = string
type SemVer = `${Major}.${Minor}.${Patch}`
 
const valid: SemVer = '0.0.1'     // ✅
const invalid: SemVer = 'xxx'     // ❌ 不满足 x.x.x 模式

object 的多种声明方式真的一样吗?

const obj: object = { name: 'jack' }
const anotherObj: {} = { name: 'larry' }
const indexedObj: { name: string } = { name: 'tom' }

运行时基本没有差别,但在类型操作中差别就体现出来了:

let objKey: keyof typeof obj               // never
let anotherObjKey: keyof typeof anotherObj  // never
let indexedObjKey: keyof typeof indexedObj   // "name"

带具体属性的声明方式更接近结构体的概念,拥有明确的索引类型。实际编码中推荐使用 Record<string, T> 来替代裸 object

type vs interface:什么时候该用哪个?

官方文档的解释:

Almost all features of an interface are available in type, the key distinction is that a type cannot be re-opened to add new properties vs an interface which is always extendable.

关键差异在可扩展性

// interface 可以声明合并
interface Plugin {}
interface Plugin {
  $translate(msg: string): string
}
// ✅ 两个声明被自动合并
 
// type 不能重复声明
type Component = {}
type Component = { $$type: string }
// ❌ Duplicate identifier

实践建议:如果你在开发公共库,需要让使用方扩展类型,用 interface。其他场景用 type 更灵活。

类型兼容性:最容易踩坑的地方

TypeScript 是结构类型系统(鸭子类型),类型之间的关系主要看结构是否兼容。这在复杂场景中会产生微妙的问题。

基础类型:子类型可以向上赋值

这很直觉——'hello'string 的子类型,所以可以赋值给 string 变量。

联合类型:关系是反过来的

对于集合类型,超集是更具体的(元素更多),子集是更宽泛的(元素更少):

type AB = 'a' | 'b'
type ABC = 'a' | 'b' | 'c'
 
let ab: AB
let abc: ABC
ab = abc  // ❌ ABC 不能赋值给 AB(ABC 更具体,不能赋值给更宽泛的 AB)

协变与逆变:函数参数的陷阱

这是 TypeScript 类型系统中最容易让人困惑的部分。

协变(Covariant)——衍生复合类型保持同一方向的可赋值性:

const mouseEvents: MouseEvent[] = [new MouseEvent('click')]
const events: Event[] = mouseEvents  // ✅ 协变

逆变(Contravariant)——衍生复合类型可赋值性方向颠倒:

function handleEvent(this: Window, e: Event) {}
function handleMouseEvent(this: Window, e: MouseEvent) {}
 
window.addEventListener('load', handleMouseEvent)
// ❌ 严格模式下报错!MouseEvent handler 不能赋值给 Event callback
 
window.addEventListener('click', handleEvent)
// ✅ 逆变:上层类型 handler 可以赋值给子类型 callback

为什么? 因为 addEventListener('load', ...) 的回调签名期望 Event 类型参数。如果传入只处理 MouseEvent 的函数,当事件类型是 KeyboardEvent 时就会出问题——这在运行时是不安全的。

💡 开启 strictFunctionTypes 后,函数参数变成逆变检查。这是推荐的配置。

超类型:any、unknown、never

never 的实战用法

never 不只是"不存在的类型",它在实际开发中有非常实用的场景:

穷举检查——确保 switch 覆盖了所有情况:

type OrderStatus = 1 | 2 | 3 | 4
 
function handleStatus(status: OrderStatus) {
  switch (status) {
    case 1:
    case 2:
    case 3:
      doSomething()
      break
    default:
      const exhaustive: never = status
      // ❌ 如果漏掉了 case 4,这里会报类型错误
  }
}

类型过滤——提取特定条件的属性名:

type Person = { name: string; age: number; address: string }
 
type StringKeys = {
  [K in keyof Person]: Person[K] extends string ? K : never
}[keyof Person]
// "name" | "address"

never 作为全局子类型,在联合操作中会被自动合并掉:"name" | "address" | never"name" | "address"

类型操作符

infer:从复合类型中提取内部类型

实际开发中,经常需要获取被包裹的内部类型——函数返回值、Promise 的 resolve 结果等:

type InferPromise<T extends Promise<any>> =
  T extends Promise<infer R> ? R : never
 
type MyReturnType<T extends (...args: any) => any> =
  T extends (...args: any) => infer R ? R : any

分布式条件类型

extends 的左侧是裸类型参数时,会自动分发成联合类型:

type Wrap<T> = T extends any ? { o: T } : never
 
type Result = Wrap<string | number>
// 等价于 Wrap<string> | Wrap<number>
// = { o: string } | { o: number }

如果不想分发,把类型参数"包起来":

type WrapNoDistribute<T> = { o: T } extends any ? { o: T } : never
 
type Result = WrapNoDistribute<string | number>
// = { o: string | number }  // 不分发

泛型 vs 联合类型

泛型和联合类型在很多场景下看起来等价,但泛型在类型收窄方面更强:

type ProductOrder = { orderNo: string; status: number; detail: {} }
type ServiceOrder = { orderNo: string; status: number; warranty: {} }
 
// 联合类型版本:返回值丢失具体类型
function filterByUnion(orders: (ProductOrder | ServiceOrder)[]) {
  return orders.filter(o => o.status >= 500)
}
 
// 泛型版本:返回值保留传入的具体类型
function filterByGeneric<T extends ProductOrder | ServiceOrder>(orders: T[]) {
  return orders.filter(o => o.status >= 500)
}
 
const productOrders: ProductOrder[] = [{ orderNo: '', status: 500, detail: {} }]
 
// 联合版本:order 是 ProductOrder | ServiceOrder
filterByUnion(productOrders).forEach(order => {
  order.detail  // ❌ 类型错误:ServiceOrder 上不存在 detail
})
 
// 泛型版本:order 仍然是 ProductOrder
filterByGeneric(productOrders).forEach(order => {
  order.detail  // ✅ 类型正确
})

原则:泛型优于联合类型,尤其在需要保留输入类型精度的场景。

工程实践原则

1. 避免重复的类型定义

这是最常见的坏味道。违反 DRY 原则的类型定义会导致一处更改、多处维护:

// ❌ 重复定义
interface Person { name: string; age: number }
interface PersonWithBirthday { name: string; age: number; date: string }
 
// ✅ 基于已有类型扩展
interface PersonWithBirthday extends Person { date: string }
// 或
type PersonWithBirthday = Person & { date: string }

2. 类型断言只用于编译器推断失效的场景

// ❌ 危险的断言:运行时可能 null
const person = <Person[]>[
  { name: 'Jack', age: 18 },
  { name: 'Larry', age: null },
]
 
// ❌ 更危险:欺骗编译器
export function getTimestamp(date = new Date()) {
  return dayjs(date).valueOf() as unknown as string
}

断言应该只在与类型定义不完善的第三方库协作、复杂的链式调用缺乏类型定义等场景下使用。

3. 优先使用 unknown 而非 any

any 绕过所有类型检测。unknown 同样接受任何赋值,但在使用时必须配合类型守卫/断言,更安全:

function isObject(target: unknown): target is object {
  return !!target && typeof target === 'object'
}
 
const obj: unknown = {}
if (isObject(obj)) {
  // 在这个分支中,obj 的类型被收窄为 object
}

4. 善用函数重载约束参数关系

学习 DOM 类型定义中的 addEventListener 重载模式——通过第一个参数的类型约束回调函数的参数类型:

// 受 DOM 类型定义启发的 Bridge 调用设计
interface CallTypeMap {
  openURL: [url: string, onClose?: () => void]
  getStorageValue: [key: string]
  setStorageValue: [opt: { key: string; value: any; expire?: number }]
}
 
function call<K extends keyof CallTypeMap, R = unknown>(
  type: K,
  ...args: CallTypeMap[K]
): R

命名元组 + 泛型约束,可以精确描述复杂的函数签名。

总结

TypeScript 的价值不在于"加了类型就完事了",而在于:

  • 类型兼容性决定了你的代码能不能安全地互操作
  • 协变逆变决定了函数参数和回调是否类型安全
  • 泛型优于联合类型在需要保留类型精度的场景
  • 工程原则(DRY、避免断言、优先 unknown)决定了项目长期的可维护性

与其追求"类型体操"的炫技,不如把这些基础用好——够用的 TypeScript,才是最好的 TypeScript

Comments