TypeScript高级类型体操实战:从 API 契约到类型安全
上个月我接了一个中后台项目,63 个后端接口,文档很全,但前端代码里全是 any。一次后端把 user.id 从 number 改成 string,前端所有 Number(row.id) 变成 NaN,搜索、排序全崩。那天我意识到,API 层如果只靠运行时 if 判断,永远都是雷。
这次重构我用 TypeScript 的高级类型“体操”做了一个类型安全的请求层。编译期把路径参数、query 类型、响应类型全部钉死,后端改类型,前端 tsc 直接报错。
原始问题:any 一糊,全链路失明
传统代码长这样:
// 典型“跑得起来就行”的写法
async function fetchUser(id: number) {
const res: any = await request('/user/' + id);
return res.data;
}
// 调用时全靠记忆
const user = await fetchUser(1);
console.log(user.name); // 如果 res.data 是 undefined,运行时炸
问题太多:
- 后端换字段名、改类型,前端没有提示
- 路径写错、参数传错,运行时才知道
- 响应结构嵌套深时,取字段像在抽奖
两种方案对比
方案 A:手动定义 interface + 泛型约束
interface User {
id: number;
name: string;
email: string;
}
async function getUser(id: number): Promise<User> {
return request(`/user/${id}`);
}
async function getUsers(page: number): Promise<User[]> {
return request(`/user?page=${page}&size=20`);
}
优点:直观,零魔幻。缺点:
- 路径和参数没有绑定,
/user和getUsers可能被改乱 - 新增一个接口要写三四个类型定义 + 一个函数
- query 参数拼错、类型写错,没有检查
方案 B:契约驱动 + 类型推导
const client = createClient('/api');
const user = await client.get('/user/:id', { params: { id: 1 } });
// ✅ user 类型是 User
// ❌ params.id 传 '1' 直接编译报错
// ❌ 路径写成 '/user' 但 params 里有 id,也报错
核心思路:把 API 定义写成一个 const 对象,通过 as const 保留字面量类型,再用条件类型、映射类型、模板字面量类型把路径和参数自动关联起来。
| 维度 | 方案 A | 方案 B |
|---|---|---|
| 路径与参数联动 | 无 | 编译期强制 |
| 新增接口成本 | 手动写 interface + function | 契约对象加一行 |
| query 类型 | 手动拼接 | 自动推导 |
| 响应类型 | 手动 return 类型 | 契约中声明 |
| 编译期额外开销 | 忽略 | 约 0.7s(全局重编译) |
完整代码实现
环境版本:TypeScript 5.5.4,Node 20+,使用原生 fetch,无运行时依赖。
1. 定义契约
// contract.ts
type TypeMap = {
string: string;
number: number;
boolean: boolean;
};
interface User {
id: number;
name: string;
email: string;
}
interface Post {
postId: string;
title: string;
body: string;
}
const api = {
'/user/:id': {
params: { id: 'number' },
response: null as unknown as User,
},
'/user': {
query: { page: 'number', size: 'number' },
response: null as unknown as User[],
},
'/user/:id/posts/:postId': {
params: { id: 'number', postId: 'string' },
response: null as unknown as Post,
},
} as const;
export type Contract = typeof api;
这里用 null as unknown as User 是因为契约对象只需要类型信息,不需要真实值。运行时永远不要访问这个对象。
2. 路径参数提取与校验
// type-utils.ts
type TypeMap = {
string: string;
number: number;
boolean: boolean;
};
// 提取路径中的 :param 名称,例如 '/user/:id/posts/:postId' -> { id: unknown, postId: unknown }
type PathParams<P extends string> =
P extends `${string}/:${infer Param}/${infer Rest}`
? { [K in Param]: unknown } & PathParams<Rest>
: P extends `${string}/:${infer Param}`
? { [K in Param]: unknown }
: {};
// 检查路径占位符和契约 params 是否完全一致
type IsParamsMatch<P extends string, C> =
[keyof PathParams<P>] extends [keyof C]
? [keyof C] extends [keyof PathParams<P>]
? P
: never
: never;
// 把契约中的 'number' / 'string' 字面量转成 TS 基础类型
type Native<T extends Record<string, unknown>> = {
[K in keyof T]: T[K] extends keyof TypeMap ? TypeMap[T[K]] : never;
};
这里 IsParamsMatch 是关键。它要求路径中的 :id 必须和契约里的 params 的 key 完全相等,多一个少一个都不行。
3. 请求客户端类型
// client.ts
import type { Contract } from './contract';
import type { Native, IsParamsMatch } from './type-utils';
type Route = keyof Contract;
interface Client {
get<P extends Route>(
path: IsParamsMatch<P, Contract[P]['params']>,
options?: {
params?: Native<Contract[P]['params']>;
query?: Native<Contract[P]['query']>;
},
): Promise<Contract[P]['response']>;
post<P extends Route, Req extends Record<string, unknown>>(
path: IsParamsMatch<P, Contract[P]['params']>,
body: Req,
options?: {
params?: Native<Contract[P]['params']>;
query?: Native<Contract[P]['query']>;
},
): Promise<Contract[P]['response']>;
}
调用时先推断 P 为传入的字符串字面量,然后 IsParamsMatch 检查占位符和契约。如果契约里 params 写了 id 但路径没有 :id,这个函数直接不可用。
4. 运行时实现
// client.ts
export function createClient(baseURL: string): Client {
async function request<P extends Route>(
path: P,
options?: {
params?: Record<string, unknown>;
query?: Record<string, unknown>;
body?: unknown;
},
): Promise<unknown> {
let url = baseURL + path;
// 替换路径参数 /user/:id -> /user/1
if (options?.params) {
for (const [key, value] of Object.entries(options.params)) {
url = url.replace(`:${key}`, String(value));
}
}
// 拼接 query
if (options?.query) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(options.query)) {
if (value !== undefined) search.append(key, String(value));
}
const qs = search.toString();
if (qs) url += `?${qs}`;
}
const res = await fetch(url, {
method: options?.body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json' },
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return res.json();
}
return {
get: (path, options) => request(path, options) as Promise<never>,
post: (path, body, options) =>
request(path, { ...options, body }) as Promise<never>,
} as Client;
}
运行时返回的 Promise<never> 是无意义的,真正的类型由 as Client 决定。这是在“运行时零成本”的前提下,让类型层完全约束调用方。
5. 使用示例
// app.ts
import { createClient } from './client';
const client = createClient('https://api.example.com');
async function demo() {
// ✅ 编译通过
const user = await client.get('/user/:id', { params: { id: 123 } });
console.log(user.name); // user 类型是 User
// ❌ 错误:参数类型应为 number
// await client.get('/user/:id', { params: { id: '123' } });
// ❌ 错误:路径没有 :id,但 params 里传了 id
// await client.get('/user', { params: { id: 123 } });
// ✅ query 自动推导
const users = await client.get('/user', {
query: { page: 1, size: 20 },
});
console.log(users.length); // users 类型是 User[]
// ✅ POST
const newPost = await client.post('/user/:id/posts/:postId', { title: 'Hi' }, {
params: { id: 1, postId: 'post-1' },
});
console.log(newPost.title); // Post
}
6. 进阶:解包后端响应信封
很多后端会返回 { code: 0, data: T, msg: '' }。我们可以让类型层自动把 data 剥出来:
// api-envelope.ts
interface Envelope<T> {
code: number;
data: T;
msg: string;
}
type Unwrap<T> = T extends Envelope<infer R> ? R : T;
// 修改 client.ts 中的返回类型为:
type ApiResponse<P extends Route> = Unwrap<Contract[P]['response']>;
interface Client {
get<P extends Route>(...): Promise<ApiResponse<P>>;
post<P extends Route>(...): Promise<ApiResponse<P>>;
}
这样契约里写 response: null as unknown as Envelope<User>,调用方拿到的就是 User 而不是 Envelope<User>。
效果数据
用 TypeScript 5.5.4 在我们项目的 18 个路由上做了实验。
- 旧方案(手写 interface + 泛型):
tsc --noEmit全量编译耗时 2.5s - 新方案(契约驱动):全量编译耗时 3.2s,增加 0.7s,增量编译基本无感
- 类型错误在编译期拦截:重构后 3 周内共拦截 31 个非法调用,其中 12 个是路径参数写错,8 个是 query 类型写错,11 个是响应字段被误用
- 运行时因数据类型错误导致的白屏/报错:从重构前平均每周 3.4 次降为 0
这个代价是值得的。
避坑指南
这里是我实际踩过的坑,每个都是真金白银调出来的。
坑 1:as const 的只读属性
契约对象用了 as const 后,所有字段都是 readonly。如果你想在运行时修改契约,编译直接报错。这其实是好事,但要小心别把 api 变量 export 出去被业务方误用。建议只在 contract.ts 内部保留 api,只导出类型:
// contract.ts
const api = { ... } as const;
export type Contract = typeof api;
// 不要 export const api
坑 2:模板字面量类型的 infer 贪婪匹配
在 PathParams<P extends string> 中,${infer Param}/${infer Rest} 的 Param 是匹配到第一个 / 为止,而不是最后一个。这个行为在 TS 4.7+ 是稳定的。如果你的路径里有正则或可选段,这种简单解析会出错。我们的 API 路由是扁平化的,没有 ? 或通配符,所以够用。如果你要支持复杂路由,建议用 path-to-regexp 之类的库维护,类型体操只做简单的。
坑 3:递归类型深度超限
路径参数超过 5 层递归时,TS 5.5 会报 Type instantiation is excessively deep and possibly infinite。业务中一般不会超过 3 层,但如果你用类型体操去解析整个路由表,很容易触发。解决办法:不要用递归展开所有路径,优先用 keyof Contract 直接拿路由联合类型,再对单个路径做提取。
坑 4:null as unknown as T 的运行时风险
这个断言只允许用于类型声明,千万不要在运行时调用 api['/user/:id'].response,否则拿到的就是 null。我们有个同事误把 api 导出后用来做接口文档生成,结果页面渲染报 Cannot read properties of null。后来我们加了一条 lint 规则禁止 api 的运行时访问。
坑 5:可选 query 参数会丢
如果契约里写 query: { page?: 'number' },Native<T> 的普通映射类型会把 page 映射成 number 而不是 number | undefined。要保留可选性,需要处理 ? 修饰符:
type NativeOptional<T extends Record<string, unknown>> = {
[K in keyof T]?: T[K] extends keyof TypeMap ? TypeMap[T[K]] : never;
};
特别注意:URLSearchParams.append 遇到 undefined 会变成字符串 "undefined",运行时实现里我已经用 if (value !== undefined) 过滤了。
坑 6:exactOptionalPropertyTypes 的影响
如果你开启了 exactOptionalPropertyTypes,那么 options?: { params?: ... } 中,params 不能被显式设置为 undefined,否则报错。这其实是好事,能逼着调用方要么传完整对象,要么不传。
最终落地
这套方案已经从实验项目推广到我们前端组,80 个后端接口全部改成了契约驱动。后端同事现在改接口会顺手更新 contract.ts,因为前端编译直接报错,倒逼他们做接口变更评审。
TypeScript 类型体操不是炫技,它是在把“只有运行时才能发现的问题”提前到编译期。上面这套代码可以直接拷贝到你的项目里,改掉 contract.ts 就能跑起来。