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 是维护型项目的杀手锏,路由增删只改一处
相关推荐
小四的小六1 小时前
AI产品翻车实录:模型总结幻觉的三大类型与检测方案
前端·openai·ai编程
狂师1 小时前
端开发必装 Skill 清单:6 款 AI 技能,让你的开发效率原地起飞
前端·程序员·开源
3630458411 小时前
KlineChartQuant Tooltip 高频交互 200FPS 性能优化完整复盘
前端·架构
嘉泰姆半导体官方1 小时前
CXAR41337C 双通道数字音频功放 | 2×20W | EQ+DRC | I²S | MCLK-Less - 嘉泰姆电子
前端·javascript·数据库
PedroQue991 小时前
uni-router v2.0.0:四大功能拆分为插件,按需加载
前端·uni-app
Lazy_zheng1 小时前
TDD 实战:Claude Code Superpowers 保姆级教程14 个 Skills 全解析 + 实战开发
前端·claude·vibecoding
胡萝卜术1 小时前
从逐一合并到多路归并:力扣23「合并K个升序链表」的三种解法进化之路
前端·javascript·面试
名字还没想好☜1 小时前
React 受控与非受控组件:表单场景怎么选
前端·javascript·react.js·react·表单·受控组件
Damai1 小时前
Radix UI导致右侧白条
前端