1. 为什么API请求的类型安全是个"系统工程"而不是一个泛型函数
先说个真实的踩坑经历。之前我们团队维护一个中后台管理系统,后端同事某天把用户列表接口里的字段 avatar 改成了 avatarUrl,按说这种改动在前后端联调时应该第一时间被发现,但实际情况是——接口文档更新了、后端自测通过了,前端在浏览器里看到用户头像集体消失,报错还是在线上被用户反馈后才定位到的。整个过程中TypeScript没有给出任何提示,因为代码里 user.avatar 的类型检查是通过了的,编译期一切正常,问题出在运行时数据结构变了,而TS在编译期根本感知不到。
这就是类型安全在API场景下最容易被误解的地方:接口返回的数据类型,和你在TS里声明的类型,完全不是一回事。TS类型只在编译期存在,它约束的是"你写的代码怎么用这个数据",而不是"网络那边实际会返回什么数据"。很多团队所谓"类型安全的API层",其实就是给axios加了个泛型参数,比如:
typescript复制const res = await http.get<User[]>('/api/users')
这当然比全部用 any 强,但它只解决了50%的问题。它保证的是"你假设返回的是 User[],然后用这个假设去写后续逻辑",可它没法保证"真实数据真的符合 User 结构"。一旦后端改了字段名、改了嵌套结构、或者联调时返回了错误码对应的非预期结构,类型系统完全无能为力。
所以我在实际项目中越来越倾向于一个观点:**API请求的类型安全,核心不在于"写一个泛型函数",而在于构建一套从编译期到运行时都能守住数据契约的体系。**它至少包含三层:
- 传输层的类型安全:请求参数、URL、Method的约束,避免把参数拼错、URL写错、类型传错。
- 数据结构层的类型安全:响应数据的静态类型定义,以及运行时对真实数据的校验,确保后端返回的数据确实符合预期结构。
- 逻辑层的类型安全:错误处理、状态管理中的数据流转,不能因为API调用就出现
any泄漏。
这三层缺一不可。只做第一层,你的axios封装得再漂亮,也只是"有个类型外壳的请求工具";做到第二层,才算真正能防住"后端偷偷改字段"这类线上事故;做到第三层,你的错误处理才不会变成 catch (e) { console.log(e) } 这种谁都说不清错误是什么的代码。
这篇文章我不打算给一个"万能封装库",而是想完整梳理一套可以在项目里直接落地的方案,包含请求层设计、响应的运行时校验、错误类型的收敛,以及几个容易被忽略的边界场景。全程用TypeScript,请求库以axios为例,校验库用zod,理由后面会讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 请求层设计:让每个接口自带类型约束,而不是散落的axios调用
2.1 从axios实例开始,统一类型入口
先看最基础的封装。很多人写axios就是 axios.get() 到处调,这样问题的根源在于:类型信息只能通过泛型参数每次手动传,而且 axios.get<T> 的 T 是"你期望的返回类型",但axios原始类型里实际返回的是 AxiosResponse<T>,用起来还得解包。更麻烦的是,如果项目里有人漏写了泛型,那这一行代码的返回类型就变成了 AxiosResponse<any>,any 就会顺着调用链一路泄漏下去,什么类型安全都会失效。
我建议的做法是先把axios实例封装成一个"默认带响应拦截器处理"的实例,再基于这个实例封装不同类型的请求方法。先看一个最基础的版本:
typescript复制// src/lib/http.ts
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from 'axios'
// 项目里统一约定:后端业务响应包裹结构
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export class HttpClient {
private instance: AxiosInstance
constructor(baseURL: string) {
this.instance = axios.create({
baseURL,
timeout: 10000,
})
this.instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse<any>>) => {
// 这里的解包逻辑,把业务数据直接返回给调用方
return response.data.data
},
(error) => {
return Promise.reject(error)
}
)
}
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.instance.get(url, config) as Promise<T>
}
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.instance.post(url, data, config) as Promise<T>
}
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.instance.put(url, data, config) as Promise<T>
}
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.instance.delete(url, config) as Promise<T>
}
}
export const http = new HttpClient(import.meta.env.VITE_API_BASE_URL || '/api')
这套封装的核心思路是"解包"。大部分后端接口都有统一包裹层,比如 { code: 0, message: 'ok', data: ... },你完全可以利用响应拦截器把这层壳拆掉,让调用方拿到的直接是业务数据。于是 http.get<User[]>('/users') 返回的就是 Promise<User[]>,而不是 Promise<AxiosResponse<ApiResponse<User[]>>>,这大大减轻了调用方的心智负担。
