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。