用 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 客户端不是银弹,但它的收益很明确:
- 编译期捕获 90% 的接口调用错误 — 不用等到上线才发现参数传错了
- 自动类型推导 — 不用手写 response 类型,IDE 自动补全
- 重构安全 — 改了接口定义,所有调用处自动报错
唯一的成本是前期定义 ApiRoutes 的时间。如果你有 OpenAPI 文档,连这个成本都可以省掉。
下次再看到
as any,想想它是不是真的必要。
#TypeScript #前端 #API #类型系统
0 评论
评论区
登录 后参与评论