TypeScript 泛型约束的 3 层演进:从 `extends` 到条件类型到重载推导

🎯 问题场景

团队有个「通用请求封装」函数,一开始很简单:

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 是维护型项目的杀手锏,路由增删只改一处
相关推荐
果然_3 小时前
纯前端图片压缩怎么做?不用上传服务器,浏览器里跑完所有逻辑
前端
Csvn3 小时前
🖼️ OffscreenCanvas:把 Canvas 绘制搬出主线程,动画卡顿的终极解药
前端
用户69371750013843 小时前
了解一下 Agent Harness
android·前端·后端
swipe4 小时前
16|(前端转全栈)前端人排查后端问题:curl、traceId、日志、MySQL、Redis 怎么用?
前端·后端·面试
anyup4 小时前
uni-app 没有根组件?仅需几行代码实现全局 Toast 和 Modal
前端·架构·uni-app
并不喜欢吃鱼4 小时前
一.前端web开发:零基础吃透 HTML5 核心知识
前端·html·html5
Hilaku4 小时前
前端真的比后端简单吗?
前端·javascript·程序员
Canace4 小时前
为了给视频里的人脸打码,我让 Claude 做了个 Skill
前端·人工智能
zhedream4 小时前
a-select / a-input 自定义下拉、Vue2 Fragment ,以及 transfer-dom
前端·vue.js