跳到主内容

一文搞懂泛型封装 HTTP:响应关联与错误收窄 ​

为什么简单封装会丢失响应类型 ​

在前端项目里直接使用 fetch、axios 或各团队自研的 HTTP 客户端,常见问题不是“能不能发请求”,而是请求成功后 data 仍然是 any,业务失败时错误对象也无法判断结构。于是页面代码中会出现大量 as UserInfo、(error as any).code、重复的接口注释和运行时判断。

一个类型安全的 HTTP 请求库至少要解决三件事:

  1. 请求方法和 URL 能关联到正确的响应类型;
  2. 查询参数、请求体与接口定义保持一致;
  3. 成功结果和业务错误使用可辨识联合表达,调用处可以自动收窄。

本文用 TypeScript 泛型实现一个不依赖具体请求引擎的轻量封装。核心思路是先定义“接口地图”,再让 request 方法从地图中提取参数和响应类型;异常不直接抛出黑盒 Error,而是返回标准化的结果联合,由业务代码显式处理。

方案对比:泛型参数与接口地图 ​

常见封装有三种方式。

方案调用方式类型安全维护成本
调用处手动传泛型http.get<UserInfo>('/user')依赖人记忆,URL 与类型可能不匹配低
每个接口单独写函数getUserInfo()高接口多时样板代码多
接口地图 + 条件类型提取http.get('getUserInfo')高,集中维护,自动提示完整初期需要设计类型

第三种最适合中大型项目。它把后端接口契约集中放在一个类型文件中,请求库只负责根据 key 推导参数和返回值。

ApiMap 接口地图参数 + 响应契约泛型 request条件类型提取页面调用代码获得自动提示SuccessResultok: trueErrorResultok: false + code

定义接口地图与统一响应结构 ​

先约定后端返回格式。下面示例中,HTTP 状态码为 200 时,业务层仍可能通过 code 表示失败,例如登录过期、参数校验失败等。

typescript
// api-types.ts

// 每个接口描述请求方法、路径参数、查询参数、请求体和成功数据
export interface ApiMap {
  getUserInfo: {\n    method: 'GET';
    path: '/users/:id';
    params: { id: string };
    query?: { includePosts?: boolean };
    body?: never;
    data: {
      id: string;
      name: string;
      role: 'admin' | 'user';
    };
  };
  createPost: {
    method: 'POST';
    path: '/posts';
    params?: never;
    query?: never;
    body: {
      title: string;
      content: string;
    };
    data: {
      id: number;
      title: string;
    };
  };
}

// 后端统一包裹格式
export interface ApiEnvelope<T> {
  code: 0 | 401 | 422 | 500;
  message: string;
  data: T;
}

// 业务错误必须携带 ok 字段,供类型收窄使用
export type ApiErrorCode = 401 | 422 | 500;

export interface SuccessResult<T> {
  ok: true;
  data: T;
}

export interface ErrorResult<C extends ApiErrorCode = ApiErrorCode> {
  ok: false;
  code: C;
  message: string;
}

export type ApiResult<T> = SuccessResult<T> | ErrorResult;

这里不要把错误设计成 throw new Error(message)。抛出异常后,TypeScript 无法通过普通条件判断帮助调用方收窄类型,而且还会强制业务代码写 try/catch。对于可预期的业务失败,返回联合类型更清晰;网络中断、代码 bug 等不可预期异常,仍可以保留抛出机制。

使用泛型提取请求参数与响应数据 ​

接下来实现核心类型工具。

typescript
// http-types.ts
import type { ApiMap } from './api-types';

type ApiKey = keyof ApiMap;

// 根据接口 key 提取某一个接口定义
type ApiDefinition<K extends ApiKey> = ApiMap[K];

// 路径参数存在时必须传入,不存在时不允许传 params
type RequestOptions<D> = D extends { params: infer P }
  ? Omit<D, 'params' | 'query' | 'body' | 'data'> & {
      params: P;
      query?: D extends { query: infer Q } ? Q : never;
      body?: D extends { body: infer B } ? B : never;
    }
  : Omit<D, 'params' | 'query' | 'body' | 'data'> & {
      params?: never;
      query?: D extends { query: infer Q } ? Q : never;
      body?: D extends { body: infer B } ? B : never;
    };

// 将 /users/:id 替换为 //users/123
function compilePath(path: string, params?: Record<string, string | number>) {
  return path.replace(/:([^/]+)/g, (_, key: string) => {
    const value = params?.[key];
    if (value === undefined) {
      throw new Error(`缺少路径参数:${key}`);
    }
    return encodeURIComponent(String(value));
  });
}

function buildQuery(query?: Record<string, unknown>) {
  if (!query) return '';
  const search = new URLSearchParams();
  Object.entries(query).forEach(([key, value]) => {
    if (value !== undefined && value !== null) {
      search.set(key, String(value));
    }
  });
  const text = search.toString();
  return text ? `?${text}` : '';
}

RequestOptions<D> 的重点是利用条件类型判断 params 是否必填。很多封装为了省事把参数都写成可选,结果调用 /users/:id 时忘记传 id,编译阶段却不报错。

实现类型安全的 HttpClient ​

下面代码可以直接在浏览器环境运行,底层使用原生 fetch。

typescript
// http-client.ts
import type { ApiEnvelope, ApiMap, ApiResult } from './api-types';
import type { RequestOptions } from './http-types';
import { buildQuery, compilePath } from './http-types';

type ApiKey = keyof ApiMap;

class HttpClient {
  constructor(private readonly baseURL = '') {}

  async request<K extends ApiKey>(
    key: K,
    options: RequestOptions<ApiMap[K]>
  ): Promise<ApiResult<ApiMap[K]['data']>> {
    const definition = options as ApiMap[K] & RequestOptions<ApiMap[K]>;
    const url =
      this.baseURL +
      compilePath(definition.path, definition.params as Record<string, string>) +
      buildQuery(definition.query as Record<string, unknown>);

    try {
      const response = await fetch(url, {
        method: definition.method,
        headers: {
          'Content-Type': 'application/json',
        },
        // GET 请求不能携带 JSON body
        body:
          definition.method === 'GET'
            ? undefined
            : JSON.stringify(definition.body ?? {}),
      });

      // 401 等 HTTP 状态也转换成标准错误联合
      if (response.status === 401) {
        return {
          ok: false,
          code: 401,
          message: '登录已过期,请重新登录',
        };
      }

      if (!response.ok) {
        return {
          ok: false,
          code: 500,
          message: `HTTP 请求失败:${response.status}`,
        };
      }

      const envelope = (await response.json()) as ApiEnvelope<
        ApiMap[K]['data']
      >;

      if (envelope.code !== 0) {
        return {
          ok: false,
          code: envelope.code,
          message: envelope.message,
        };
      }

      return {
        ok: true,
        data: envelope.data,
      };
    } catch (error) {
      // 网络异常、JSON 解析失败属于不可预期失败
      return {
        ok: false,
        code: 500,
        message: error instanceof Error ? error.message : '未知网络错误',
      };
    }
  }

  get<K extends Extract<ApiKey, 'getUserInfo'>>(
    key: K,
    options: RequestOptions<ApiMap[K]>
  ) {
    return this.request(key, options);
  }

  post<K extends Extract<ApiKey, 'createPost'>>(
    key: K,
    options: RequestOptions<ApiMap[K]>
  ) {
    return this.request(key, options);
  }
}

export const http = new HttpClient('/api');

实际项目中可以进一步根据 ApiMap[K]['method'] 自动限制 get、post 方法的 key,而不必手写 Extract。例如增加一个工具类型:

typescript
type KeysOfMethod<M extends ApiMap[ApiKey]['method']> = {
  [K in ApiKey]: ApiMap[K]['method'] extends M ? K : never;
}[ApiKey];

// get 方法中使用 K extends KeysOfMethod<'GET'>
// post 方法中使用 K extends KeysOfMethod<'POST'>

调用处完成错误联合收窄 ​

封装完成后,页面或 Pinia、Vuex 等状态管理模块可以这样使用:

typescript
import { http } from './http-client';

async function renderUser() {
  const result = await http.request('getUserInfo', {
    method: 'GET',
    path: '/users/:id',
    params: { id: 'u_1001' },
    query: { includePosts: true },
  });

  // 关键:通过 ok 字段收窄类型
  if (!result.ok) {
    // 这里 result 的类型是 ErrorResult
    if (result.code === 401) {
      console.log('跳转登录页');
    } else if (result.code === 422) {
      console.log('参数错误:', result.message);
    } else {
      console.log('服务暂不可用');
    }
    return;
  }

  // 这里 result 的类型是 SuccessResult<UserInfo>
  console.log(result.data.name, result.data.role);
}

在 Vue 3 中也推荐保留这种显式分支:

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { http } from './http-client';

const userName = ref('');
const errorMessage = ref('');

onMounted(async () => {
  const result = await http.request('getUserInfo', {
    method: 'GET',
    path: '/users/:id',
    params: { id: 'u_1001' },
  });

  if (result.ok) {
    userName.value = result.data.name;
  } else {
    errorMessage.value = result.message;
  }
});
</script>

<template>
  <div v-if="errorMessage" class="error">{{ errorMessage }}</div>
  <div v-else>{{ userName }}</div>
</template>

避坑总结:泛型封装最容易踩的 4 个坑 ​

1. 响应类型写成 any,调用处仍然需要断言 ​

错误现象:封装看似使用了泛型,但底层写成 const data = await response.json() as any,再 return data as T。页面里虽然能访问字段,但拼错字段名、后端改结构都不会报错。

修正方法:统一通过 ApiEnvelope<ApiMap[K]['data']> 标注解析结果,最终返回 SuccessResult<T> | ErrorResult,不要在业务代码中使用 as UserInfo 补类型。

2. 只靠 Error 类无法收窄业务错误 ​

错误现象:所有失败都 throw new Error(message),调用方只能写 catch (error),然后访问 (error as any).code。TypeScript 不知道 code 是否存在,401、422 等分支也没有提示。

修正方法:可预期业务失败返回 { ok: false, code, message },成功返回 { ok: true, data }。使用 if (result.ok) 或 if (!result.ok) 让 TypeScript 自动收窄。

3. 可选参数设计过宽,漏传路径参数不报错 ​

错误现象:为了同时适配有参数和无参数接口,把 params、body 都写成可选对象。请求 /users/:id 忘记传 id 时,编译通过,运行时才出现 /users/:id 或 undefined。

修正方法:用条件类型区分 D extends { params: infer P },有路径参数的接口必须传 params,无路径参数的接口将其标为 never 或省略。

4. GET 请求携带 body 或 query 拼接未编码 ​

错误现象:封装中无条件执行 JSON.stringify(options.body),GET 请求被部分浏览器、代理或网关拒绝;搜索关键字包含 &、中文、空格时,URL 被破坏。

修正方法:GET 方法强制 body: undefined;查询参数使用 URLSearchParams,路径参数使用 encodeURIComponent。同时过滤 undefined 和 null,避免把无效值发送给后端。

结语 ​

TypeScript 泛型封装 HTTP 请求库的价值,不在于写出多复杂的类型体操,而在于让接口契约成为唯一事实来源。通过 ApiMap 关联路径、参数和响应数据,再用可辨识联合表达成功与失败,调用方能同时获得自动补全、编译校验和错误收窄能力。后续接入接口文档生成、Mock 服务或请求缓存时,也都可以继续复用这套类型基础。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。