前端开发2026-03-17 14 分钟

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 关键字可以让你创建自己的类型守卫函数,比 typeofinstanceof 灵活得多。

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

最后的建议

TypeScript 的类型系统是图灵完备的,理论上你可以在类型层面实现任何计算。但在实际项目中,可读性永远比「炫技」重要。上面的 10 个技巧已经能覆盖 95% 的业务场景。如果你发现自己写出了超过 3 层嵌套的条件类型,大概率是设计有问题——退一步,用运行时代码解决可能更合适。想了解更多前端工程化知识,也欢迎看看 Vue3 源码解析Vite 工作原理