🎯 问题场景
团队有个「通用请求封装」函数,一开始很简单:
ts
async function request<T>(url: string): Promise<T> {
const res = await fetch(url)
return res.json()
}
用着用着需求变了:
- 约定 1 :删除接口的
DELETE请求不需要body - 约定 2 :更新接口的
PUT请求body必须有id - 约定 3 :查询接口的
GET请求自动带上token
如果全写 any,CR 肯定过不了。怎么用类型系统约束住这些规则?
🔍 原因分析
问题本质:泛型的自由度过高,无法表达业务约束。
- 裸泛型
<T>什么都能接,也什么都检查不了 any就是投降,放弃类型安全- 我们需要逐层收敛:约束类型 → 变换类型 → 推导类型
💡 解决方案
Level 1:extends 泛型约束 --- 把门框做出来
ts
// 至少确保 T 是个对象,排除 string/number 乱入
async function request<T extends Record<string, unknown>>(url: string, body: T): Promise<T> {
// ...
}
// ✅ 正确
request('/api/user', { name: '张三', age: 25 })
// ❌ 类型错误:Argument of type 'string' is not assignable
request('/api/user', 'hello')
局限性:只能约束「形状」,不能约束「字段间关系」。
Level 2:条件类型 + never 过滤 --- 精细到字段级
业务场景:PUT 请求的 body 必须包含 id 字段。
ts
// 工具类型:检测 T 是否包含 id 字段
type HasId<T> = T extends { id: string | number } ? T : never
async function putRequest<T>(url: string, body: T & HasId<T>): Promise<T> {
return fetch(url, { method: 'PUT', body: JSON.stringify(body) }).then(r => r.json())
}
// ✅ 正确
putRequest('/api/user/1', { id: '1', name: '新名字' })
// ❌ 类型报错:Type 'string' is not assignable to type 'never'
putRequest('/api/user/1', { name: '没有id' })
妙用 :条件类型遇到不满足的约束会产出 never,而 T & never = never,直接编译报错。
Level 3:函数重载 + 泛型推导 --- 根据返回值反向推断
最进阶的用法:根据传入参数推导出精确的返回类型。
ts
// 响应体类型映射
interface ApiMap {
'/api/user': { id: string; name: string; email: string }
'/api/posts': { list: Array<{ id: string; title: string }>; total: number }
'/api/config': Record<string, string>
}
// 重载 1:已知路由 → 推导响应类型
async function apiGet<T extends keyof ApiMap>(url: T): Promise<ApiMap[T]>
// 重载 2:未知路由 → 需要显式泛型
async function apiGet<T>(url: string): Promise<T>
// 实现签名
async function apiGet(url: string): Promise<unknown> {
const res = await fetch(url)
return res.json()
}
// 使用:
const user = await apiGet('/api/user')
// ^? const user: { id: string; name: string; email: string }
const posts = await apiGet('/api/posts')
// ^? const posts: { list: [...]; total: number }
const unknown = await apiGet('/api/other')
// ^? const unknown: unknown ← 不在映射表中,需要显式泛型
这一层才是工业级方案:利用函数重载的匹配优先级,先匹配精确路由签名,再降级到通用签名。
三合一:完整版请求封装
ts
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
// 约束:PUT 必须有 id
type WithId<T> = T & { id: string | number }
async function api<T extends Record<string, unknown>>(
method: 'GET' | 'DELETE',
url: string,
body?: never
): Promise<T>
async function api<T extends Record<string, unknown>>(
method: 'PUT',
url: string,
body: WithId<T>
): Promise<T>
async function api<T extends Record<string, unknown>>(
method: 'POST',
url: string,
body: T
): Promise<T>
// 实现
async function api<T>(
method: string,
url: string,
body?: unknown
): Promise<T> {
const options: RequestInit = { method }
if (body && method !== 'GET' && method !== 'DELETE') {
options.body = JSON.stringify(body)
}
return fetch(url, options).then(r => r.json())
}
// ✅ 编译通过
api('GET', '/api/user')
api('POST', '/api/user', { name: '张三' })
api('PUT', '/api/user/1', { id: '1', name: '新名字' })
// ❌ 编译报错
api('PUT', '/api/user/1', { name: '没写id' }) // 缺少 id
api('DELETE', '/api/user/1', { something: 1 }) // DELETE 不应有 body
📌 要点总结
| 层级 | 技术 | 解决什么问题 |
|---|---|---|
| Level 1 | extends 泛型约束 |
类型的大类过滤 |
| Level 2 | 条件类型 + never |
字段级约束关系 |
| Level 3 | 函数重载 + 映射类型 | 参数推导返回值 |
- 不要把
any当万能药,泛型约束只多几行代码,却能在编译期拦截大量 bug - 重载签名 > 联合类型参数:重载让每个签名的泛型推导独立,联合类型参数会丢失精度
- 映射类型(
ApiMap) 是维护型项目的杀手锏,路由增删只改一处