前端开发··2 阅读·预计 14 分钟

TypeScript 高级类型编程:用 infer 和模板字面量打造类型安全的 API 层

为什么你的 API 调用总是需要手动写类型?

很多团队在用 TypeScript 写前端时,API 层的类型定义是个老大难问题。要么手动维护一份 interface,要么直接 any 了事。今天分享一套我们团队在生产环境用了半年的方案——用 TS 高级类型从后端路由定义自动推导出前端请求/响应类型,彻底消灭 API 层的 any

先看最终效果

// 定义后端路由(共用的类型包)
const routes = {
  'GET /users':          { query: { page: number }, response: User[] },
  'GET /users/:id':      { params: { id: string }, response: User },
  'POST /users':         { body: CreateUserDto, response: User },
  'PATCH /users/:id':    { params: { id: string }, body: UpdateUserDto, response: User },
} as const;

// 前端调用——完全类型安全,零 any const users = await api.get('/users/:id', { params: { id: '123' } }); // users 的类型自动推导为 User,params.id 必须传且必须是 string

接下来一步步拆解实现。

第一步:用 const assertion 锁定路由字面量类型

关键在于 as const。不用它,TS 会把对象的类型推断得过于宽泛:

// ❌ 没有 as const
const routes = {
'GET /users': { query: { page: 1 }, response: {} as User[] },
};
// 类型是 { 'GET /users': { query: { page: number }, response: User[] } }

// ✅ 有 as const const routes = { 'GET /users': { query: { page: 1 as const }, response: [] as User[] }, } as const; // 每个属性都是 readonly,字面量类型被精确保留

实际项目中,我推荐用一个 defineRoutes 工具函数来辅助:

type RouteConfig = {
query?: Record<string, unknown>;
params?: Record<string, string>;
body?: unknown;
response: unknown;
};

function defineRoutes<T extends Record<string, RouteConfig>>(routes: T): T { return routes; }

第二步:用模板字面量类型拆解路由键

路由键的格式是 "GET /users/:id",需要拆出 HTTP method 和 path:

// 提取 Method
type ExtractMethod<K extends string> = K extends ${infer M} ${string} ? M : never;

// 提取 Path type ExtractPath<K extends string> = K extends ${string} ${infer P} ? P : never;

// 测试 type TestMethod = ExtractMethod<'GET /users/:id'>; // 'GET' type TestPath = ExtractPath<'GET /users/:id'>; // '/users/:id'

这里 infer 是核心——它让 TS 从字符串模板中"捕获"一部分内容并绑定到类型变量上。

第三步:用 infer 提取路径参数

路径 /users/:id/posts/:postId 中需要提取出 idpostId,生成 { id: string; postId: string }

// 单段提取
type ExtractSingleParam<S extends string> =
S extends :${infer P} ? P : never;

// 递归提取所有参数 type ExtractParams<P extends string> = P extends ${infer _Start}:${infer Param}/${infer Rest} ? { [K in ExtractSingleParam<:${Param}>]: string } & ExtractParams</${Rest}> : P extends ${infer _Start}:${infer Param} ? { [K in ExtractSingleParam<:${Param}>]: string } : {};

// 测试 type Params = ExtractParams<'/users/:id/posts/:postId'>; // { id: string; postId: string }

这里有个坑:infer 的匹配是贪婪的,${infer A}/${infer B} 会把尽可能多的内容分给 A。所以我们在 Start 位置用 infer _(下划线前缀告诉 TS 这个变量不用)来"吞掉"前面的固定部分。

第四步:组装类型安全的 API 函数

type RouteMap = typeof routes;
type RouteKey = keyof RouteMap & string;

type MethodOf<K extends RouteKey> = ExtractMethod<K> extends infer M ? M extends string ? Lowercase<M> : never : never;

type GetRouteConfig<K extends RouteKey> = RouteMap[K]; type GetResponse<K extends RouteKey> = GetRouteConfig<K>['response'];

// 构建请求参数类型 type RequestConfig<K extends RouteKey> = { params: ExtractParams<ExtractPath<K>> extends infer P ? keyof P extends never ? undefined : P : never; } & (GetRouteConfig<K> extends { query: infer Q } ? { query: Q } : {}) & (GetRouteConfig<K> extends { body: infer B } ? { body: B } : {});

这里的 extends infer P 模式是关键技巧——它让 TS 先执行一次类型计算,把结果绑定到 P,然后在条件分支中使用。这比直接内联整个表达式更清晰,也避免了一些 TS 不能很好推断的场景。

第五步:最终的 API 函数签名

type FilterByMethod<M extends string, K extends string> =
K extends ${M} ${string} ? K : never;

type ApiFn = { get<K extends FilterByMethod<'GET', RouteKey>>( path: ExtractPath<K>, ...args: keyof RequestConfig<K>['params'] extends never ? [config?: { query?: GetRouteConfig<K> extends { query: infer Q } ? Q : never }] : [config: RequestConfig<K>] ): Promise<GetResponse<K>>;

post<K extends FilterByMethod<'POST', RouteKey>>( path: ExtractPath<K>, config: RequestConfig<K> ): Promise<GetResponse<K>>; };

// 使用 declare const api: ApiFn;

// ✅ 自动推导出 User const user = await api.get('/users/:id', { params: { id: 'abc' } });

// ❌ 编译时报错:缺少 params.id const user2 = await api.get('/users/:id', {});

// ❌ 编译时报错:path 拼写错误 const user3 = await api.get('/usr/:id', { params: { id: 'abc' } });

实战中的坑和经验

坑1:as const 导致的 readonly 问题

as const 会让所有属性变成 readonly,如果你的 DTO 需要复用为写入类型,需要 DeepMutable 工具类型:

type DeepMutable<T> = {
-readonly [K in keyof T]: T[K] extends object ? DeepMutable<T[K]> : T[K];
};

坑2:联合类型分发问题

当你对联合类型做条件类型判断时,TS 会自动分发(distribute):

type Test = 'GET /users' | 'POST /users' extends ${infer M} ${string} ? M : never;
// 结果是 'GET' | 'POST'——这是分发的结果,通常是我们想要的

// 但如果不想要分发,用 [] 包裹 type NoDistribute<T> = [T] extends [${infer M} ${string}] ? M : never;

坑3:类型推断深度限制

TS 对递归类型有深度限制(默认 50 层)。如果你的路径参数提取递归过深,可以改成迭代式写法或拆分类型。实际项目中,5 层以内的路径参数不会触发这个问题。

和 OpenAPI 生成方案的对比

你可能会问:既然有 openapi-typescript 这种从 OpenAPI spec 生成类型的工具,为什么还要手写?

  • 手写方案适合:monorepo 项目、前后端共用类型包、路由定义在代码里而不是 YAML 文件中的场景
  • OpenAPI 生成适合:已有完善的 API 文档、需要支持多语言客户端、对外暴露的开放 API

我们 monorepo 项目选择手写方案,因为路由定义就在 packages/shared/src/routes.ts,改了路由,前端类型立刻同步,不需要额外的 codegen 步骤。

总结

这套方案的核心就三板斧:

  1. as const 保留字面量类型
  2. infer 从字符串模板中提取类型信息
  3. 条件类型 + 交叉类型组装最终的请求/响应类型

投入不大(200 行类型代码),收益很稳——半年内我们因为 API 类型不匹配导致的线上 bug 降到了零。如果你的项目也是 TypeScript monorepo,强烈建议试试。

0 评论

评论区

登录 后参与评论