Back to Blog

TypeScript 的类型体操到底有没有用?

TypeScript 泛型推断 类型体操 any Zod
ErpanOmer
ErpanOmer
· 2026-10-21 · 14 min read
TypeScript 的类型体操到底有没有用?

之前我花了差不多三天时间,给我们项目的表单引擎写了一套完整的泛型推断系统。

从字段配置到校验规则到提交数据,全链路类型安全,VS Code 悬浮上去每一层都能精准推导出当前字段的类型。我自己调通之后,心里是有点小得意的——这大概是我写过最精致的一组类型定义了😃。

然后我发了 PR。

第二天早上,我在代码仓库里看到了一条新提交记录。同事小杨基于我那套表单引擎新增了一个业务表单,提交信息写的是:feat: 新增供应商入驻表单。

我点开一看,打眼一行:

const formData = ref<any>({})

三天。三天的泛型推断,被一个 any 在 5 分钟之内绕过了。

我当时的表情大概是这样的 🤷‍♂️。

不是生气,是突然清醒了——你写的类型再精致,如果团队里的人不理解、不愿意用、或者用不了,那这些类型就只是你一个人的自嗨。

这件事让我认真反思了一个问题:在真实的工程团队里,TypeScript 的类型体操,到底值不值得投入?边界在哪里?下面我们细聊👇


先承认一个事实:大多数前端团队的 TS 水平是参差不齐的

这不是在瞧不起谁,这是工程现实🤔。

一个典型的国内前端团队,通常长这样:

  • 1 到 2 个对 TypeScript 理解比较深的高级工程师,能写泛型、条件类型、infer 推断
  • 3 到 5 个日常开发者,TypeScript 对他们来说就是给变量加个 : string,interface 会写,泛型看得懂但写不出
  • 偶尔还有外包或者实习生,写 any 是常态,能跑就行

当你在这种团队里写出下面这段类型定义的时候:

type InferFieldType<T extends FieldConfig> = 
  T extends { type: 'text' } ? string :
  T extends { type: 'number' } ? number :
  T extends { type: 'select'; multiple: true } ? string[] :
  T extends { type: 'select'; multiple?: false } ? string :
  T extends { type: 'date' } ? Date :
  T extends { type: 'custom'; component: infer C } ? 
    C extends { value: infer V } ? V : unknown :
  never;

type FormValues<T extends readonly FieldConfig[]> = {
  [K in T[number] as K['name']]: InferFieldType<K>;
};

你猜团队里有几个人能看懂?能看懂的人里,又有几个人在业务迭代压力下愿意去理解你这套推断链路,而不是直接 any 绕过去?


类型体操制造隐形成本

在我反思这件事之后,我回头审视了我们项目里所有精致的类型定义,发现了一个规律:越复杂的类型,造成的工程问题越隐蔽。

报错信息变成天书,根本看不懂😖

当你的泛型嵌套超过 3 层时,TypeScript 给出的报错信息会变得完全不可读。

一个普通的类型不匹配,在简单类型下可能提示的是:

Type 'string' is not assignable to type 'number'.

但在深层泛型下,同样的错误会变成:

Type 'string' is not assignable to type 
  'InferFieldType<Extract<readonly [{ readonly name: "age"; 
  readonly type: "number"; readonly required: true; }, 
  { readonly name: "name"; readonly type: "text"; }][number], 
  { name: "age" }>>'.

你自己写的时候能看懂,因为你了解每一层的上下文。但你的同事看到这段报错,第一反应只有一个:加 as any 让它闭嘴。 然后你精心设计的类型安全链路,就在最需要它发挥作用的地方失效了 😖。

编译速度肉眼可见地变慢

递归类型拖慢检查与提示.png

我们项目里有一套递归的嵌套表单类型(表单里可以嵌表单),上线后发现 tsc --noEmit 的类型检查时间从 8 秒涨到了 34 秒。每一次保存文件,IDE 的类型提示要卡 2 到 3 秒才能刷新出来。

这种级别的延迟,对开发效率的杀伤是真实的——你的同事不会去研究为什么编译变慢了,他只会觉得这个项目的 TypeScript 有问题,然后默默在 tsconfig.json 里加上 skipLibCheck: true😢。

重构成本飙升

简单类型的重构成本很低。你把一个 interface 里的字段名从 userName 改成 nickName,TypeScript 会精准地在所有引用处报错,你逐个修复就行。

字段改名与泛型重构对比.png

但当你重构一个深层泛型时,牵一发动全身。你改了 InferFieldType 里的一个分支条件,下游所有依赖这个推断结果的类型、函数签名、组件 Props 全部报错——而且报错信息是上面👆 说的那种天书格式。

最后的结果往往是:没人敢改那个泛型文件。 它变成了项目里——谁也不碰,谁也不懂,它就在那里维持着一种脆弱的平衡。


那 TypeScript 到底应该怎么用?

说了这么多类型体操的问题,我并不是在说 TypeScript 没用。恰恰相反,TypeScript 是我近 5 年做过的最正确的技术选型之一。我反对的不是 TypeScript,是不计成本地追求类型完美。

在我们团队里,我现在推行的是一套 类型预算 的思维方式:每一个类型定义都有成本,你需要判断这笔投入能不能换来足够的工程收益。

接口响应和请求参数的类型定义

这是 TypeScript 投入产出比最高的地方,没有之一。

后端返回的数据结构,是前端 Bug 的头号来源。字段名拼错、嵌套层级搞混、可选字段当必填用——这些 Bug 在运行时才会暴露,而且往往表现为页面空白或者数据不对,极难调试。

一份精确的 API 类型定义,能在编译阶段就拦住 80% 的数据层 Bug:

// 给每一个 API 的请求和响应定义精确的类型
interface UserListParams {
  page: number;
  pageSize: number;
  keyword?: string;
  status?: 'active' | 'inactive' | 'banned';
}

interface UserItem {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: string;    // ISO 8601
  lastLoginAt: string | null;  // 可能从未登录过
}

interface PaginatedResponse<T> {
  list: T[];
  total: number;
  page: number;
  pageSize: number;
}

// 请求函数签名也带上类型
export function getUserList(
  params: UserListParams,
): Promise<ApiResponse<PaginatedResponse<UserItem>>> {
  return request('/api/users', { params });
}

这段类型定义没有任何体操成分。每一行都是直球——字段叫什么、什么类型、是否可选、枚举值有哪些。团队里最初级的开发者也能看懂、能用、不会绕过去。

但它的工程价值极大:业务代码里写 user.staus 会立刻报红(拼错了),写 user.lastLoginAt.slice(0, 10) 也会报红(可能是 null),这些全是真实的线上 Bug 场景。

组件 Props 的联合类型约束

这是第二个高收益场景。用联合类型和字面量类型约束 Props,能让组件的使用方式在编译阶段就被锁死:

// 用联合类型约束组件的合法用法
type ButtonProps =
  | { variant: 'primary'; loading?: boolean; danger?: never }
  | { variant: 'danger'; loading?: boolean; danger: true }
  | { variant: 'link'; href: string; loading?: never; danger?: never };

// 使用时,TypeScript 会自动约束:
// <Button variant="link" /> → 报错:缺少 href
// <Button variant="primary" danger /> → 报错:primary 不能带 danger
// <Button variant="link" loading /> → 报错:link 不能带 loading

这不是体操,这是把组件的使用规范写进类型系统里。比任何文档和 Code Review 都可靠,因为违反规范的代码根本编译不过去。

递归类型、深层 infer、类型编程

下面👇 这类写法,除非你在写通用库或者框架,否则在业务项目里几乎不值得投入:

// 业务项目里不要写这种东西
type DeepPartial<T> = T extends object 
  ? { [K in keyof T]?: DeepPartial<T[K]> } 
  : T;

type PathOf<T, Prefix extends string = ''> = T extends object
  ? { [K in keyof T & string]: 
      `${Prefix}${K}` | PathOf<T[K], `${Prefix}${K}.`> 
    }[keyof T & string]
  : never;

// 你想实现 get(obj, 'a.b.c') 的类型安全?
// 这笔账,在业务项目里不划算

如果你真的需要 DeepPartial,直接从 type-fest 这类工具库里引就行。让专门维护类型工具的开源社区去承担复杂类型的维护成本,而不是你自己在业务项目里手写。


其实,比 any 更危险的是 as

很多团队在推行 TypeScript 的时候,会把 any 当成首要敌人。但在我看来,as 类型断言比 any 更危险。

any 至少是坦诚的放弃——你明确告诉编译器我不管了。但 as 是你告诉编译器相信我,这个类型一定是对的,然后编译器就真的不检查了。

// any:至少你知道这里没有类型保护
const data: any = await fetchSomething();

// as:编译器不会帮你检查
const user = data as UserItem;  
// 如果 data 实际上是 null / undefined / 完全不同的结构呢?
// TypeScript 不会报任何错误。

在 Code Review 里,我现在对 as 的审查比 any 更严格。如果你需要对不确定的外部数据做类型收窄,用运行时校验,而不是编译时断言:

// 用 Zod 做运行时类型守卫,比 as 安全 100 倍
import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
  lastLoginAt: z.string().datetime().nullable(),
});

type UserItem = z.infer<typeof UserSchema>;

// API 响应数据经过 Zod 校验后,类型才是可信的
const parsed = UserSchema.safeParse(rawData);
if (!parsed.success) {
  console.error('API 响应结构异常:', parsed.error.flatten());
  // 上报监控、走降级逻辑
  return;
}
// parsed.data 的类型是 UserItem,且你 100% 确定数据结构是对的
const user = parsed.data;

Zod 的 safeParse 不仅给你类型安全,还给你运行时的数据兜底。当后端悄悄改了接口字段、或者某个字段从必填变成了可选,你的页面不会白屏——它会走进 error 分支,上报监控,给用户一个降级提示。


给你的团队定一条简单的 TypeScript 规则

与其写 30 页的 TypeScript 最佳实践文档(没人看),不如就定一条规则:

在你的项目里,interface 和简单泛型(Promise<T>、Array<T>、Record<K, V>)能覆盖 95% 的场景。当你发现自己在写条件类型或者 infer 的时候,先停下来:团队里除了我,还有几个人看得懂这段类型?如果答案是 0,那就别写。

TypeScript 的价值不在于你能把类型系统玩得多花,而在于它能帮整个团队,以最低的认知成本,拦住最多的运行时错误,仅此而已。

好了,今天就分享到这里🙌

Suggestion (3).gif


前端技术官-ErpanOmer

喜欢我的 Blog 吗?你也可以部署一份属于你的第三空间!

详细的 🚀部署文档 以帮你安排好! 你的 Star 是对我持续开发的最大鼓励!点击右侧按钮前往 GitHub 支持我。

欢迎 Star !