外观
TypeScript 类型系统进阶:从类型体操到工程约束
TypeScript 用得好不好,分水岭通常不在"会不会写泛型",而在于是否理解类型是编译期的约束工具,不是目的。
团队里常见的两个极端:一是全程 any,TypeScript 退化成带类型注解的 JavaScript;二是沉迷类型体操,写出几百行没人能维护的推导类型。真正有价值的类型设计,是在关键边界上花最少的类型成本,换取最大的错误拦截能力。
从结构类型说起
TypeScript 是结构化类型系统:类型兼容看形状,不看名字。
ts
interface Point {
x: number
y: number
}
function print(point: Point) {
console.log(point.x, point.y)
}
const coordinate = { x: 1, y: 2, z: 3 }
print(coordinate) // 合法,多余属性不参与检查这带来灵活性,也带来一个常见误区:以为 interface 能保证运行时数据结构。实际上它只约束编译期,接口返回的数据仍需运行时校验。
条件类型与 infer
条件类型让类型具备分支能力,infer 则可以在匹配过程中提取子类型。
ts
type UnwrapPromise<T> = T extends Promise<infer Value>
? UnwrapPromise<Value>
: T
type ElementOf<T> = T extends readonly (infer Item)[] ? Item : never
type A = UnwrapPromise<Promise<Promise<string>>> // string
type B = ElementOf<Array<{ id: number }>> // { id: number }这两个工具类型在实际项目中非常常用:封装异步工具函数时统一解包返回值,或者从数组类型中提取元素类型,避免手写重复定义。
条件类型默认会对联合类型逐个分发:
ts
type ToArray<T> = T extends unknown ? T[] : never
type C = ToArray<string | number> // string[] | number[]利用分发可以精确处理联合类型,但如果不希望分发,需要用元组包裹:
ts
type ToArrayNoDistribute<T> = [T] extends [unknown] ? T[] : never
type D = ToArrayNoDistribute<string | number> // (string | number)[]映射类型与递归
映射类型用于批量改造对象属性,是很多工具类型的底层实现。
ts
type DeepPartial<T> = T extends (infer Item)[]
? DeepPartial<Item>[]
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T这里显式排除了数组和函数,否则数组会被当成普通对象递归处理,得到奇怪的结果。写递归类型时一定要注意终止条件,否则容易触发 "Type instantiation is excessively deep"。
另一个高频场景是精确约束一组字符串键:
ts
type AppRoutes = {
home: '/'
archive: '/archive'
post: `/posts/${string}`
}
const routes = {
home: '/',
archive: '/archive',
post: '/posts/vue3'
} as const satisfies AppRoutesas const 保留字面量类型,satisfies 在保留推导结果的同时做结构检查。相比直接标注 : AppRoutes,它能避免字面量被拓宽为 string。
用模板字面量约束事件名
事件系统是模板字面量类型的典型用武之地:
ts
type EventMap = {
change: { value: string }
submit: { values: Record<string, string> }
}
type EventHandlers<E> = {
[K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void
}
type FormHandlers = EventHandlers<EventMap>
// {
// onChange: (payload: { value: string }) => void
// onSubmit: (payload: { values: Record<string, string> }) => void
// }keyof E 是 string | number | symbol 的联合,映射到模板字面量前要约束成 string,否则编译器无法确定插值结果。
这种写法适合 SDK、埋点系统、表单引擎这类"事件名有规律"的场景。普通业务组件没必要为了炫技引入复杂推导。
用可辨识联合表达状态
比起用多个布尔值描述请求状态,可辨识联合能让非法状态无法被表达:
ts
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
function assertNever(value: never): never {
throw new Error(`Unexpected state: ${JSON.stringify(value)}`)
}
function render<T>(state: AsyncState<T>) {
switch (state.status) {
case 'idle':
return '等待开始'
case 'loading':
return '加载中'
case 'success':
return state.data
case 'error':
return state.error.message
default:
return assertNever(state)
}
}新增状态时,assertNever 会立即报错,提示所有未处理的 switch 分支。这比 loading、error、data 三个独立字段可靠得多,因为后者可以同时为真。
类型与运行时校验的分工
类型系统无法验证网络请求、用户输入、localStorage 读取等外部数据。关键边界应该引入运行时校验:
ts
import { z } from 'zod'
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
roles: z.array(z.enum(['admin', 'editor', 'viewer']))
})
type User = z.infer<typeof UserSchema>
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return UserSchema.parse(await res.json())
}z.infer 从 schema 推导类型,保证类型与校验规则来自同一处定义。原则是:只在系统边界校验,内部数据信任类型系统,不要给每个函数都套一层 parse。
值得开启的编译选项
json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"moduleResolution": "bundler",
"target": "ES2022"
}
}noUncheckedIndexedAccess:数组下标和索引签名返回值自动包含undefined,迫使处理越界情况。exactOptionalPropertyTypes:区分"属性不存在"和"属性值为 undefined",对序列化逻辑很有价值。verbatimModuleSyntax:让类型导入在编译后完全消失,避免打包器误判副作用。
开启这些选项会暴露大量存量问题,建议新项目直接开启,老项目分批处理。
常见坑
- 用类型断言代替校验。
as User不会检查数据,只会在编译期闭嘴。 - 类型推导过度。复杂工具类型应补测试(如
expectTypeOf)和注释,否则后人不敢改。 interface与type混用无规范。团队统一约定:对象结构用interface,联合、映射、工具类型用type。- 声明文件与实现漂移。手写
.d.ts容易和实现脱节,优先从源码或 OpenAPI 生成类型。 - 忽略
any的传染性。一个any会污染整条调用链,开启noImplicitAny并在 ESLint 中限制显式any。
2019 年发布的 TypeScript 3.7 引入了可选链与空值合并,4.x 又补齐了模板字面量、
satisfies等能力。版本差异较大时,先确认项目的 TS 版本,再决定能用哪些语法。
小结
类型系统的价值在于把隐式约定变成显式约束:接口形状、状态机、事件契约、配置结构。少写一些炫技类型,多把关键边界收口,TypeScript 才会真正降低维护成本。