TypeScript 不是「加个类型」这么简单:大型项目中的 10 个实战技巧
别再写 any 了。这些技巧能让 TypeScript 真正帮你「防 bug」而不是「添麻烦」。
刚开始用 TypeScript 的时候,我觉得它就是给变量加个 : string、函数加个 : void。后来接手了一个 10 万行的项目,才发现 TypeScript 的类型系统简直是另一门语言——它能做的事情远比你想象的多。
这篇文章不讲基础语法,只讲在真实业务项目中最实用的 10 个技巧。每个技巧都配有「错误示范 vs 正确写法」对比,看完直接能在项目里用。
1. Discriminated Unions:干掉 if-else 地狱
处理 API 响应时,很多人会这样写类型:
// ❌ 糟糕的写法:status 和 data/error 之间没有关联
interface ApiResponse {
status: 'success' | 'error' | 'loading'
data?: any // success 时有值
error?: string // error 时有值
}
function handle(res: ApiResponse) {
if (res.status === 'success') {
console.log(res.data) // 类型还是 any | undefined,TS 帮不了你
}
}
// ✅ 正确的写法:Discriminated Union
type ApiResponse<T> =
| { status: 'success'; data: T }
| { status: 'error'; error: string }
| { status: 'loading' }
function handle(res: ApiResponse<User>) {
if (res.status === 'success') {
console.log(res.data) // 类型自动收窄为 User,不再是 undefined
}
if (res.status === 'error') {
console.log(res.error) // 类型自动收窄为 string
}
}
原理:类型收窄(Narrowing)
TypeScript 编译器在遇到 if (res.status === 'success') 时,会根据 status 这个「判别字段」自动收窄 res 的类型。这叫 Discriminated Union(可辨识联合类型)。关键是每个分支的 status 值必须是字面量类型且互不重叠。
2. as const 满足推断:让对象变成字面量类型
定义常量映射表时,as const 是个神器:
// 不加 as const → 类型被拓宽
const STATUS = { ACTIVE: 'active', DISABLED: 'disabled' }
// typeof STATUS = { ACTIVE: string, DISABLED: string } ← 丢失了字面量
// 加 as const → 保留字面量类型
const STATUS = { ACTIVE: 'active', DISABLED: 'disabled' } as const
// typeof STATUS = { readonly ACTIVE: 'active', readonly DISABLED: 'disabled' }
// 配合 typeof + keyof 提取类型
type StatusKey = keyof typeof STATUS // 'ACTIVE' | 'DISABLED'
type StatusValue = typeof STATUS[StatusKey] // 'active' | 'disabled'
// 在函数参数中使用,获得字面量级别的类型检查
function setStatus(status: StatusValue) { ... }
setStatus('active') // ✅
setStatus('unknown') // ❌ 编译报错
3. satisfies:类型检查但不收窄推断
TypeScript 4.9 引入的 satisfies 关键字解决了一个长期痛点:你想检查一个对象是否符合某个类型,但又不想丢失具体的字面量推断。
type Route = { path: string; children?: Route[] }
type Routes = Record<string, Route>
// ❌ 用类型注解:丢失了 key 的字面量推断
const routes: Routes = {
home: { path: '/' },
about: { path: '/about' },
}
routes.home // ✅ 但 routes.xxx 也不报错,因为 key 是 string
// ✅ 用 satisfies:既检查类型,又保留字面量推断
const routes = {
home: { path: '/' },
about: { path: '/about' },
} satisfies Routes
routes.home // ✅ 有自动补全
routes.xxx // ❌ 编译报错,因为 TS 知道只有 home 和 about
4. 泛型约束 + infer:从函数返回值中提取类型
在实际项目中,经常需要「根据一个函数的返回值推导出类型」。TypeScript 内置了好几个工具类型来帮你做这件事:
// ReturnType:提取函数返回值类型
function createUser() {
return { id: 1, name: 'Tom', role: 'admin' as const }
}
type User = ReturnType<typeof createUser>
// { id: number; name: string; role: 'admin' }
// Parameters:提取函数参数类型
function updateUser(id: number, data: Partial<User>) { ... }
type UpdateArgs = Parameters<typeof updateUser>
// [id: number, data: Partial<User>]
// Awaited:解包 Promise
async function fetchUser(): Promise<User> { ... }
type ResolvedUser = Awaited<ReturnType<typeof fetchUser>>
// User(自动解包了 Promise)
实战场景
这些工具类型在 Vue 3 + Pinia 项目中特别有用。比如你有一个 useUserStore(),可以用 ReturnType<typeof useUserStore> 来获取 store 的类型,而不需要手动定义接口。当 store 的结构变化时,类型会自动同步。
5. Template Literal Types:字符串也能做类型运算
TypeScript 的模板字面量类型可以在类型层面做字符串拼接和模式匹配,非常适合处理 CSS 类名、事件名等场景。
// 事件名拼接
type EventName<T extends string> = `on${Capitalize<T>}`
type ClickEvent = EventName<'click'> // 'onClick'
type FocusEvent = EventName<'focus'> // 'onFocus'
// CSS 尺寸字符串
type Size = `${number}${'px' | 'rem' | 'em' | '%'}`
const width: Size = '100px' // ✅
const bad: Size = '100vw' // ❌ 'vw' 不在联合类型中
// API 路径拼接
type ApiPath = `/api/v${1 | 2}/${'users' | 'posts'}/${string}`
const path: ApiPath = '/api/v2/users/123' // ✅
const bad2: ApiPath = '/api/v3/users/123' // ❌ v3 不合法
6. 条件类型 + Mapped Types:按需变换接口
当你需要根据条件动态生成类型时,条件类型 + 映射类型是最强大的组合。
// 场景:把接口中所有方法变成可选的,其他属性保持不变
type OptionalMethods<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? T[K] | undefined // 是函数 → 变可选
: T[K] // 不是函数 → 保持原样
}
interface UserService {
name: string
age: number
greet(): void
save(): Promise<void>
}
type PartialService = OptionalMethods<UserService>
// { name: string; age: number; greet: (() => void) | undefined; save: ... | undefined }
// 更实用的场景:把对象所有 key 加上 'get' 前缀
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
interface State {
count: number
name: string
}
type StateGetters = Getters<State>
// { getCount: () => number; getName: () => string }
7. 品牌类型(Branded Types):让 string 和 string 不一样
这是大型项目中防止「类型混用」的终极手段。比如 UserId 和 PostId 虽然都是 string,但不应该能互相赋值。
// 定义品牌类型
type UserId = string & { readonly __brand: 'UserId' }
type PostId = string & { readonly __brand: 'PostId' }
// 创建工厂函数
function userId(id: string): UserId { return id as UserId }
function postId(id: string): PostId { return id as PostId }
// 使用
function deleteUser(id: UserId) { ... }
function deletePost(id: PostId) { ... }
const uid = userId('user-123')
const pid = postId('post-456')
deleteUser(uid) // ✅
deleteUser(pid) // ❌ 编译报错!PostId 不能赋值给 UserId
deleteUser('raw-string') // ❌ 普通 string 也不行
为什么这在大型项目中很重要?
在一个有几百个 API 接口的项目中,各种 ID 满天飞。把 userId 传给了需要 orderId 的函数是很常见的 bug,而且很难在 code review 中发现。品牌类型在编译阶段就能拦住这类错误,零运行时开销。
8. exhaustive check:确保你处理了所有分支
当你用 switch 处理联合类型时,TypeScript 可以帮你确保「没有遗漏任何 case」。
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2
case 'rect':
return shape.width * shape.height
// 忘了处理 triangle!
default:
// 这行会在编译时报错:
// Type '{ kind: "triangle"; ... }' is not assignable to type 'never'
const _exhaustive: never = shape
return _exhaustive
}
}
当你之后给 Shape 添加了新的 kind(比如 'polygon'),所有用到 exhaustive check 的 switch 语句都会自动报错提醒你补全逻辑。在大型项目中,这比靠人力 code review 可靠得多。
9. 类型谓词(Type Predicates):自定义类型守卫
TypeScript 的 is 关键字可以让你创建自己的类型守卫函数,比 typeof 和 instanceof 灵活得多。
interface Fish { swim(): void }
interface Bird { fly(): void }
// ❌ 普通函数:过滤后类型还是 (Fish | Bird)[]
function isFish(pet: Fish | Bird): boolean {
return 'swim' in pet
}
// ✅ 类型谓词:过滤后类型自动收窄为 Fish[]
function isFish(pet: Fish | Bird): pet is Fish {
return 'swim' in pet
}
const pets: (Fish | Bird)[] = getPets()
const fishes = pets.filter(isFish) // 类型是 Fish[],不是 (Fish | Bird)[]
// 实战:过滤掉 null/undefined
function isNotNullish<T>(value: T): value is NonNullable<T> {
return value !== null && value !== undefined
}
const ids: (string | null | undefined)[] = [null, 'a', undefined, 'b']
const validIds = ids.filter(isNotNullish) // string[]
10. 泛型链式推断:让复杂 API 用起来像说话
好的泛型设计能让使用者完全不需要手动写泛型参数——TypeScript 自己就能推断出来。看一个 query builder 的例子:
class QueryBuilder<T extends Record<string, any>> {
private filters: Partial<T> = {}
private selectedKeys?: (keyof T)[]
where<K extends keyof T>(key: K, value: T[K]): this {
this.filters[key] = value
return this // 返回 this 实现链式调用
}
select<K extends keyof T>(...keys: K[]): QueryBuilder<Pick<T, K>> {
this.selectedKeys = keys as any
return this as any // 缩窄返回类型
}
}
interface User { id: number; name: string; email: string; age: number }
new QueryBuilder<User>()
.where('age', 18) // key 有自动补全,value 必须是 number
.where('name', 'Tom') // key 有自动补全,value 必须是 string
.select('id', 'name') // 有自动补全,结果类型变成 Pick<User, 'id' | 'name'>
设计原则
好的泛型 API 设计遵循一个原则:让使用者尽量少写泛型参数。通过函数参数的类型推断,TypeScript 可以自动推导出泛型参数。只在「无法从参数推断」时才要求用户手动传泛型。比如 ref<number>(0) 其实可以直接写 ref(0),因为 TS 能从 0 推断出 number。