前端开发··2 阅读·预计 16 分钟

用 TypeScript 类型体操打造零运行时错误的 API 客户端

为什么你的 API 调用还是一堆类型断言?

团队里十个 fetch 调用,九个在用 as unknown as XxxResponse。接口改了字段名,编译器不报错,上线就 500。今天我们用 TypeScript 的类型系统,从根上解决这个问题。

第一步:定义接口契约

// api/types.ts - 所有接口类型集中管理
interface ApiRoutes {
  '/auth/login': {
    POST: {
      body: { email: string; password: string }
      response: { success: boolean; data: { user: User; token: string } }
    }
  }
  '/posts/:id': {
    GET: {
      params: { id: string }
      response: { success: boolean; data: Post }
    }
    PUT: {
      params: { id: string }
      body: Partial<Pick<Post, 'title' | 'content' | 'categoryId'>>
      response: { success: boolean; data: Post }
    }
    DELETE: {
      params: { id: string }
      response: { success: boolean; message: string }
    }
  }
}

// 提取路径参数的类型工具
type ExtractParams<Path extends string> =
  Path extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<Rest>]: string }
    : Path extends `${infer _Start}:${infer Param}`
      ? { [K in Param]: string }
      : {}

// 测试
type Params = ExtractParams<'/posts/:id/comments/:commentId'>
// → { id: string; commentId: string }

第二步:构建类型安全的请求函数

核心思路:用泛型约束让编译器帮你检查请求参数。

// api/client.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

type RouteConfig = {
  [Path in keyof ApiRoutes]: {
    [Method in keyof ApiRoutes[Path]]: {
      params?: ExtractParams<Path>
      body?: ApiRoutes[Path][Method] extends { body: infer B } ? B : never
      response: ApiRoutes[Path][Method] extends { response: infer R } ? R : never
    }
  }
}

class ApiClient {
  private baseUrl: string
  private token: string | null = null

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  setToken(token: string) {
    this.token = token
  }

  async request<
    Path extends keyof ApiRoutes,
    Method extends keyof ApiRoutes[Path] & string
  >(
    path: Path,
    method: Method,
    options: Omit<RouteConfig[Path][Method], 'response'> & {
      params?: ExtractParams<Path>
    }
  ): Promise<RouteConfig[Path][Method]['response']> {
    // 替换路径参数 :id -> 实际值
    let url = path as string
    if (options.params) {
      for (const [key, value] of Object.entries(options.params)) {
        url = url.replace(`:${key}`, encodeURIComponent(value))
      }
    }

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    }
    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`
    }

    const resp = await fetch(`${this.baseUrl}${url}`, {
      method,
      headers,
      body: 'body' in options ? JSON.stringify((options as any).body) : undefined,
    })

    if (!resp.ok) {
      const error = await resp.json().catch(() => ({ message: resp.statusText }))
      throw new ApiError(resp.status, error.message || '请求失败')
    }

    return resp.json()
  }

  // 快捷方法
  get<Path extends keyof ApiRoutes>(
    path: Path,
    options?: Omit<RouteConfig[Path]['GET'], 'response'>
  ) {
    return this.request(path, 'GET', options as any)
  }

  post<Path extends keyof ApiRoutes>(
    path: Path,
    options: Omit<RouteConfig[Path]['POST'], 'response'>
  ) {
    return this.request(path, 'POST', options as any)
  }
}

class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message)
    this.name = 'ApiError'
  }
}

第三步:实际使用 — 编译器就是你的测试工具

// 使用示例
const api = new ApiClient('https://climberzbm.cn/api')
api.setToken('your-jwt-token')

// ✅ 正确用法 — 编译通过
const loginResult = await api.post('/auth/login', {
  body: { email: 'admin@climberzbm.cn', password: 'admin123' }
})
console.log(loginResult.data.user.username) // 完整类型推导

const post = await api.get('/posts/:id', {
  params: { id: 'cmmump4920002vi5r13wts6p1' }
})
console.log(post.data.title) // Post 类型,有完整提示

// ❌ 错误用法 — 编译器直接报错
const bad = await api.post('/auth/login', {
  body: { emial: 'admin@climberzbm.cn' } // TS Error: emial 不存在于 { email, password }
})

const bad2 = await api.post('/posts/:id', {
  params: { id: 123 } // TS Error: 应为 string,不是 number
})

进阶:自动生成类型定义

如果后端是自己的,可以用 OpenAPI 自动生成类型:

// scripts/gen-api-types.ts
import * as fs from 'fs'
import * as yaml from 'js-yaml'

// 从 OpenAPI spec 生成 ApiRoutes 类型
function generateTypes(spec: any): string {
  const lines: string[] = ['interface ApiRoutes {']

  for (const [path, methods] of Object.entries(spec.paths)) {
    lines.push(`  '${path}': {`)
    for (const [method, detail] of Object.entries(methods as any)) {
      const upperMethod = method.toUpperCase()
      if (!['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(upperMethod)) continue

      lines.push(`    ${upperMethod}: {`)

      // params
      const params = (detail as any).parameters?.filter((p: any) => p.in === 'path')
      if (params?.length) {
        lines.push(`      params: {`)
        params.forEach((p: any) => {
          lines.push(`        ${p.name}: ${schemaToTsType(p.schema)}`)
        })
        lines.push(`      }`)
      }

      // body
      const bodySchema = (detail as any).requestBody?.content?.['application/json']?.schema
      if (bodySchema) {
        lines.push(`      body: ${schemaToTsType(bodySchema)}`)
      }

      // response
      const respSchema = (detail as any).responses?.['200']?.content?.['application/json']?.schema
      lines.push(`      response: ${respSchema ? schemaToTsType(respSchema) : 'void'}`)

      lines.push(`    }`)
    }
    lines.push(`  }`)
  }
  lines.push(`}`)

  return lines.join('\n')
}

实战踩坑记录

1. 可选字段的坑

// 错误:PUT 请求 body 可选字段没处理
body: Post  // 整个 Post 都传?不对

// 正确:只允许修改特定字段,且都是可选
body: Partial<Pick<Post, 'title' | 'content' | 'categoryId'>>

2. 泛型约束顺序很重要

// 错误:Method 泛型没有依赖 Path,导致 Method 可以是任意路由的方法
async request<Method extends string, Path extends keyof ApiRoutes>

// 正确:Method 必须是 Path 下存在的方法
async request<Path extends keyof ApiRoutes, Method extends keyof ApiRoutes[Path]>

3. 路径参数替换时的顺序问题

// 错误:/posts/:id/comments/:commentId 替换时,:id 会匹配到 :commentId 中的 :id
url.replace(':id', params.id) // /posts/123/comments/123mentId 😱

// 正确:先替换更长的参数名,或者用正则精确匹配
const sortedParams = Object.entries(params).sort((a, b) => b[0].length - a[0].length)
for (const [key, value] of sortedParams) {
  url = url.replace(`:${key}`, encodeURIComponent(value))
}

总结

类型安全的 API 客户端不是银弹,但它的收益很明确:

  1. 编译期捕获 90% 的接口调用错误 — 不用等到上线才发现参数传错了
  2. 自动类型推导 — 不用手写 response 类型,IDE 自动补全
  3. 重构安全 — 改了接口定义,所有调用处自动报错

唯一的成本是前期定义 ApiRoutes 的时间。如果你有 OpenAPI 文档,连这个成本都可以省掉。

下次再看到 as any,想想它是不是真的必要。

#TypeScript #前端 #API #类型系统

0 评论

评论区

登录 后参与评论