TypeScript高级类型体操实战
发布日期: 2026/08/16 阅读总量: 0

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`);
}

优点:直观,零魔幻。缺点:

  • 路径和参数没有绑定,/usergetUsers 可能被改乱
  • 新增一个接口要写三四个类型定义 + 一个函数
  • 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.4Node 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 就能跑起来。