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[]>>>,这大大减轻了调用方的心智负担。
2.2 为什么泛型方法要单独封装,而不是直接用axios实例
直接暴露axios实例其实也能用,但有几个问题:
- 泛型泄漏风险:
axios.get('/users')不传泛型时返回Promise<AxiosResponse<any>>,这个any会被赋值到你的业务变量上,后续所有类型检查全部失效。 - 校验时机太晚:axios泛型是在"你调用时"手动指定的,没有集中统一处理。接口一多,就会出现"有人传
User[],有人传any,还有人传错了类型"的混乱局面。 - 解包逻辑重复:如果每处调用都写
.data.data,不只是代码丑,而且解包后的类型需要你每次手动声明,更容易出错。
所以一个自带泛型且已做解包的请求方法,本质上是在"强制"每个API函数声明返回类型。我习惯在项目里再封装一层API模块,比如 src/api/user.ts:
typescript复制// src/api/user.ts
import { http } from '@/lib/http'
export interface User {
id: string
name: string
email: string
avatar: string
createdAt: string
}
export interface GetUsersParams {
page: number
pageSize: number
keyword?: string
}
export const getUsers = (params: GetUsersParams) => {
return http.get<User[]>('/users', { params })
}
export const getUserById = (id: string) => {
return http.get<User>(`/users/${id}`)
}
这样的话,所有业务方只需要 import { getUsers, type User } from '@/api/user',不需要去关心 http 是怎么来的,也不会有任何人绕过API模块直接调用axios。我在代码审查时就明确规定:业务代码里不允许出现 axios 这个标识符,所有请求必须走API模块。这一条规则能避免大量类型混乱和重复代码。
2.3 关于类型守卫与as的使用边界
有同学会问:上面封装里用了 as Promise<T>,这算不算类型不安全?
严格意义上,as 是在"骗"编译器,告诉它"这个返回值的形状符合 T"。但我们要分清"运行时校验"和"编译期静态类型"两个层面。这一步的 as 其实是合理的——因为我们已经通过响应拦截器将 axios 原本返回的结构做了转换,axios 的类型定义无法感知这个转换,所以需要手动断言。真正的风险不在于这里的 as,而在于"你断言之后,真实数据是否真的符合 T"。这恰恰是下一章要讲的运行时校验要解决的问题。
所以我的建议是:请求层用 as 是允许的,但要确保"静态类型 T 与真实数据的一致性"在后续运行时校验中兜底。 单纯把 as 当万能工具用,比如从 any 一路断言到某个复杂类型,那才是类型安全彻底失效的开始。
3. 响应数据才是重灾区:运行时校验,补上编译期管不到的漏洞
3.1 TS类型是编译期的"可信谎言",运行时校验才是保命符
刚才说 http.get<User[]>('/users') 是"假设返回的是 User[]"。可你有没有想过,如果后端这次返回的不是数组,而是一个 { code: 500, message: '服务器异常', data: null } 呢?TS不会报错,你的 users.map(...) 会在运行时直接炸掉。
用生活里的例子理解:TS类型就像你出门前看天气预报说"今天晴天不用带伞",zod这类运行时校验则像你出门后抬头看天空,发现乌云密布立刻回家拿伞。前者是静态预测,后者是动态检查。API数据来自网络,天然不可信,所以响应数据这一层必须做运行时校验。
目前TypeScript生态里做运行时校验的主流方案是 zod。它的一大特点是可以"从schema反推出TS类型",这样你不需要维护两份类型定义:
typescript复制// src/api/user.ts
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatar: z.string().url(),
createdAt: z.string(),
})
export type User = z.infer<typeof UserSchema>
这里 z.infer 从schema推导出了TS类型,以后后端改了字段,你只需要改schema,TS类型自动跟着变,不会出现"类型和校验规则不一致"这种更隐蔽的坑。
3.2 在请求方法里统一接入解析逻辑
单纯定义schema还不够,你得在每个接口返回后统一执行 parse。接上一章的封装,把校验逻辑加进去:
typescript复制// src/api/user.ts
import { http } from '@/lib/http'
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatar: z.string().url(),
createdAt: z.string(),
})
export type User = z.infer<typeof UserSchema>
export const getUserById = async (id: string) => {
const raw = await http.get<unknown>(`/users/${id}`)
return UserSchema.parse(raw)
}
这里有个细节要注意:http.get<unknown> 而不是 http.get<User>。因为运行时校验的核心逻辑是"我不知道你要给我什么,先拿原始数据来验"。如果一开始就写了 http.get<User>,raw 的类型就是 User,你后续再 UserSchema.parse(raw) 在类型层面就完全没意义——编译器已经认为它是 User 了,zod的校验结果类型还是 User,等于校验了个寂寞。用 unknown 作为起始类型,zod.parse 的结果类型才能正确推导为 User。
不过 http.get<unknown> 也有个体验问题:每一处API调用都要记得写 <unknown>,而且后续所有数据都要手动 .parse,代码会有点啰嗦。所以我通常会在请求封装之上再封装一个小工具函数,把"请求+校验"合二为一:
typescript复制// src/lib/requestWithSchema.ts
import { http } from '@/lib/http'
import { z } from 'zod'
export const getWithSchema = async <T extends z.ZodTypeAny>(
url: string,
schema: T,
config?: Parameters<typeof http.get>[1]
): Promise<z.infer<T>> => {
const raw = await http.get<unknown>(url, config)
return schema.parse(raw)
}
export const postWithSchema = async <T extends z.ZodTypeAny>(
url: string,
schema: T,
data?: unknown,
config?: Parameters<typeof http.post>[2]
): Promise<z.infer<T>> => {
const raw = await http.post<unknown>(url, data, config)
return schema.parse(raw)
}
然后API模块就可以写得很清爽:
typescript复制// src/api/user.ts
import { getWithSchema } from '@/lib/requestWithSchema'
import { z } from 'zod'
export const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
})
export type User = z.infer<typeof UserSchema>
export const getUsers = (params: { page: number }) =>
getWithSchema('/users', z.array(UserSchema), { params })
export const getUserById = (id: string) =>
getWithSchema(`/users/${id}`, UserSchema)
调用方的体验极其舒适:const users = await getUsers({ page: 1 }),users 自动是 User[],而且这个 User[] 是经过了运行时校验的,不再只是编译期假设。
3.3 校验失败时的降级与信息保留
zod.parse 在校验失败时会抛出 ZodError,异常信息很详细。但线上环境不能直接把 zod 的错误堆栈抛给用户,所以我会在请求封装里加一层统一的错误归一化:
typescript复制// src/lib/handleError.ts
import { ZodError } from 'zod'
export class ApiRequestError extends Error {
constructor(
message: string,
public readonly cause?: unknown,
public readonly zodIssues?: ZodError['issues']
) {
super(message)
this.name = 'ApiRequestError'
}
}
export const normalizeError = (err: unknown): ApiRequestError => {
if (err instanceof ApiRequestError) {
return err
}
if (err instanceof ZodError) {
return new ApiRequestError(
`接口返回数据格式校验失败: ${err.issues.map(i => i.path.join('.')).join(', ')}`,
err,
err.issues
)
}
if (err instanceof Error) {
return new ApiRequestError(err.message, err)
}
return new ApiRequestError('未知错误', err)
}
然后在拦截器里统一捕获:
typescript复制this.instance.interceptors.response.use(
(response) => response.data.data,
(error) => {
return Promise.reject(normalizeError(error))
}
)
这样业务方 catch (e) 拿到的永远是 ApiRequestError,可以安全访问 .message、.zodIssues,而不是一个毫无约束的 unknown 或 any。后续如果想做错误上报,也能从 zodIssues 里精确拿到是哪个字段、哪条路径校验失败,排查效率高很多。
3.4 一个容易被忽略的请求:列表接口的校验
很多团队只对详情接口做schema校验,列表接口嫌麻烦就跳过了。但列表接口恰恰是最容易出问题的——分页参数一变、后端偷偷把 null 塞进数组、某个字段缺了,这些在列表场景下的破坏力比单个详情大得多,因为一个列表页可能同时渲染几百条数据。
所以列表类的接口我建议用 z.array(UserSchema) 包一层。有人担心 z.array 校验大量数据会不会影响性能,实测下来,zod对一个包含几百条对象的数组做校验,耗时通常在一两毫秒级别,相对网络请求的几十到几百毫秒来说完全可以忽略。真正遇到极端性能场景(比如上万条数据),可以再考虑用 zod .safeParse 做非抛错校验,或在前端做抽样校验。但从经验看,绝大多数业务系统到不了那一步,该校验就校验,别提前优化。
4. 错误信息的类型化处理:从catch (e: any)到可辨识联合
4.1 为什么错误处理也需要类型安全
如果你在一个项目里搜索 catch (e,大概率会看到 catch (e: any)、catch (e: unknown) 然后 console.log(e) 的写法。这其实是类型安全最薄弱的环节之一:错误对象是不可信任的,是来自运行时而不是编译期的数据,但很多人只把静态类型用在了"正常返回数据"上,对错误类型完全不设防。
类型安全的错误处理目标很明确:**让每个catch分支都能拿到结构化的错误信息,并且在编译期就能区分出"网络错误""业务错误""数据校验错误"这几类不同情况。**这样才能做到"网络错误提示重试、业务错误提示后端返回的message、校验错误提示前端排查"这种精细化处理,而不是一律弹个"请求失败"。
4.2 用可辨识联合统一错误对象
前面我们引入了 ApiRequestError,接下来可以把错误细分得更合理。假设我们想区分以下场景:
- 网络层错误(超时、连接失败)
- HTTP状态码错误(401、403、500等)
- 后端业务错误(HTTP 200但业务code非0)
- 数据校验错误(zod校验失败)
它们本质上是不同类型的问题,处理方式也不同。可以这样设计:
typescript复制// src/lib/errors.ts
import { ZodError } from 'zod'
export type ApiError =
| { kind: 'NETWORK_ERROR'; message: string; originalError: unknown }
| { kind: 'HTTP_ERROR'; status: number; message: string }
| { kind: 'BIZ_ERROR'; code: number; message: string }
| { kind: 'VALIDATION_ERROR'; message: string; issues: ZodError['issues'] }
然后在拦截器里,根据错误来源返回对应的可辨识联合对象。这样业务方就能用 switch 或 if 收紧判断:
typescript复制// 业务代码示例
const getUsersAction = async () => {
try {
const users = await getUsers({ page: 1 })
// 正常渲染
} catch (e) {
const err = e as ApiError
switch (err.kind) {
case 'NETWORK_ERROR':
showToast('网络异常,请检查网络连接')
break
case 'HTTP_ERROR':
if (err.status === 401) {
redirectToLogin()
} else {
showToast(`请求失败(${err.status})`)
}
break
case 'BIZ_ERROR':
showToast(err.message)
break
case 'VALIDATION_ERROR':
reportError(err) // 这类错误通常不该出现在用户端,更多是前后端不一致
break
}
}
}
这时如果某个分支漏了,TypeScript会直接编译报错(因为可辨识联合没有被穷尽检查),这比 catch (e: any) 在安全性上强了一个量级。可辨识联合的核心价值,就是把"错误有多少种、每种有什么字段"显式地告诉编译器。
4.3 注意:axios的错误会被拦截器改写,那as ApiError安全吗
这里有个细节要说明白。上面的例子中 e as ApiError 也是一种断言。为什么这里敢断言?
因为在拦截器中我们已经统一做了错误归一化。也就是说,所有从请求层抛出的错误,都是经过 normalizeError 处理过的。如果 normalizeError 返回的都是 ApiError 可辨识联合里的具体对象,那这个断言就是与运行时行为一致的。关键是要保证:拦截器是唯一抛出错误的地方,业务代码里不要再直接 throw new Error(...) 绕过这个建模。
为了让 as 的痕迹更少,也可以把错误处理工具函数封装一下:
typescript复制// src/lib/errorHandler.ts
import { ApiError } from './errors'
export const toApiError = (e: unknown): ApiError => {
return e as ApiError // 因为拦截器已经保证
}
export async function handleApiAction<T>(
action: Promise<T>,
handlers: {
onError: (err: ApiError) => void
}
): Promise<T | undefined> {
try {
return await action
} catch (e) {
handlers.onError(toApiError(e))
return undefined
}
}
不过这种封装容易显得过于抽象,我在项目中更多是让业务方直接 catch,然后借助 toApiError 消除 as 的重复。毕竟类型安全不是消灭所有断言,而是把断言集中到可信的地方。
5. 生产环境还会遇到的几个边界场景:分页、取消请求、缓存与类型生成
5.1 分页响应的类型抽象:别让{ list, total }散落到各处
分页是后台系统里最常见的模式。如果不做抽象,每个列表API都会写一遍 { list: User[], total: number } 或 { records: User[], total: 100 },而且字段名还不一样(有的用list,有的用records,有的用items),类型安全很容易在这些细节上失效。
我建议统一一个分页响应包装类型,并且连schema一起封装:
typescript复制// src/lib/pagination.ts
import { z } from 'zod'
export const paginationSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
z.object({
list: z.array(itemSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
hasMore: z.boolean(),
})
export type Paginated<T> = {
list: T[]
total: number
page: number
pageSize: number
hasMore: boolean
}
然后列表API可以这样写:
typescript复制// src/api/user.ts
export const getUsers = (params: { page: number; pageSize: number }) =>
getWithSchema('/users', paginationSchema(UserSchema), { params })
调用方拿到的直接是 Paginated<User>。如果后端分页结构字段改了,比如把 list 改成了 items,只需要在 paginationSchema 里改一个字段名,所有调用方的类型会立刻报错并提示,这比线上问题反馈要高效得多。
5.2 取消请求与类型安全的配合
前端经常需要在组件卸载时取消未完成的请求,避免内存泄漏和竞态。axios 提供了 AbortController 或 CancelToken,但从类型安全角度它们有个小坑:取消请求时的错误类型要和普通错误区分开。
默认情况下,使用 AbortController 取消的请求,axios 会抛出一个 CanceledError。如果我们前面 normalizeError 没有处理这种情况,它会被包成一个 ApiRequestError,业务方误以为真的是请求失败,然后弹出"网络异常"。这明显不对。
我的处理方式是在 normalizeError 里识别取消场景:
typescript复制// src/lib/handleError.ts
import axios from 'axios'
export const isCancelError = (err: unknown): boolean => {
return axios.isCancel(err)
}
export const normalizeError = (err: unknown): ApiError | { kind: 'CANCELED' } => {
if (axios.isCancel(err)) {
return { kind: 'CANCELED' }
}
// ...其他逻辑
}
这样业务方在catch里可以优先判断 if (err.kind === 'CANCELED') return,而取消请求本身不会打扰用户。
另外有个经验:像搜索框这种高频场景,一定要配合 AbortController 做竞态控制。比如用户输入"abc",连续发送了三个请求,前两个请求返回得慢,最后一个返回快,如果没有取消机制,前两个慢请求可能覆盖掉最后一个结果,导致页面显示和当前输入不匹配。用 AbortController 把前两个请求取消掉,可以有效规避这类接口竞态问题。
5.3 缓存和类型安全:从"闭眼读缓存"到"校验后再用"
不少项目会在前端做API数据缓存,比如用 react-query 或自定义的Map缓存。但很多人忽略了:缓存的本质是把一次网络返回的数据存下来,下次直接用,跨过了运行时校验这一层。 如果缓存的数据是旧版本的schema(比如后端升级后,缓存里还是老结构),直接读取可能踩坑。
一个既简单又实用的建议:读取缓存时也做一次zod校验。 比如用 react-query,可以在 select 或 queryFn 里做解析。数据量不大时,这点校验开销可以忽略,但能防止"缓存的数据与当前期望结构不一致"这种隐蔽问题。当然,如果你的缓存策略带了版本号或TTL很短,可以酌情跳过,但默认我建议加上。
5.4 类型自动化:openapi-typescript 把后端契约变成TS类型
手动维护所有API模块和zod schema,对团队来说还是有一定工作量的。更省力的思路是:如果后端有OpenAPI/Swagger文档,可以直接用它生成TS类型定义,再基于这些定义开发请求模块。
openapi-typescript 是一个很成熟的工具,可以生成完整的接口和类型定义。但要注意,它生成的是"静态类型",不是"运行时校验"。所以我在团队里的实践往往是双轨并行:
- 用
openapi-typescript自动生成请求参数和响应结构的基础类型,减少手写重复类型。 - 对关键业务字段或高风险的接口,再手动补充 zod schema 做运行时校验。
这样既利用了自动化提高效率,又守住了运行时校验这个保命底线。接口变化时,重新跑一遍生成命令,所有类型不一致的地方都会在编译期暴露出来。
5.5 几个实战中的常见坑,提前帮你踩平
- Zod版本与TS版本的兼容性:zod的某些特性需要较新的TypeScript版本支持。项目初始化时先确认TS版本,避免后续升级zod时报类型错误。
z.infer和z.input的差异:当schema里用了z.transform或默认值时,z.input是"输入侧类型",z.infer是"输出侧类型",两者可能不同。推荐在API层使用z.infer作为返回值类型,不要混淆。- 不要在API模块里直接返回 z.infer 类型之外的任何字段:有些同学喜欢在后端返回的数据上自己补一个字段,比如
createdAt + ' ' + ...,但这样做会让"运行时真实数据"和"schema校验结果"出现偏差。建议所有派生数据都放到业务层再处理,保持API模块的数据干净原始。 - 错误边界要考虑"后端返回字符串data"这种极端情况:有些后端接口在不同code下返回的data结构完全不一样,比如失败时返回
data: { errorMessage: 'xxx' },成功时返回data: { list: [...] }。这时zod校验可以直接用z.discriminatedUnion来建模,这也是可辨识联合在schema层的对应物,能让校验规则更严谨。
6. 落地建议:一套"最小但完整"的配置清单
最后根据我多个项目的实践,总结一套可以直接抄作业的最小化落地清单,供大家搭架子时参考。不要一上来就整一个大而全的封装框架,小而清晰、可持续演进,比什么都重要。
- 请求层:axios实例 + 响应拦截器解包 + 统一请求方法,这一步大约100行代码。
- API模块层:按业务域拆分
src/api/*.ts,每个接口一个函数,返回类型必须明确。 - 运行时校验层:引入zod,定义核心实体的schema,用
z.infer派生静态类型。 - 统一错误处理层:定义可辨识联合的错误类型,在拦截器里归一化,业务侧
switch (err.kind)处理。 - 团队规范:业务代码禁止直接调用axios;禁止在API模块里返回
any;对列表接口和详情接口至少做一层schema校验;缓存读取也要校验。
这套方案的收益是长期且复利的。刚开始多写的schema代码,会在每次后端接口变更、每次联调、每次线上排障时带来回报。尤其是运行时报错从"用户看到头像消失了"变成"错误监控系统里精确显示 UserSchema.name 校验失败",这种体验一旦试过,就很难退回"能跑就行"的写法。
