TypeScript:把"可维护"写进编译期

为什么它值得单独写一篇

很多人把 TypeScript 当成"带类型的 JavaScript",然后觉得它不过是给编辑器加个提示。这个理解低估了它。TypeScript 真正做的事情是:把一批只有在运行时才会暴露的错误,提前到编译期,并且不产生任何额外代码成本(除了类型声明本身)。它是一层可以随时剥掉的壳——编译产物是纯 JavaScript,运行时不检查任何东西。

这个设计的取舍非常重要:它选择了"零运行时开销 + 类型只是一次性校验",代价是"无法保证运行时数据你一定对"。这句话是理解 TS 所有边界的前提。

从 2012 年那个失败的项目说起

Anders Hejlsberg 是 C# 和 Delphi 的作者,他在微软带队做了一个尝试:给 JavaScript 加类型。最初它是为某个内部项目准备的,2012 年 10 月公开,2014 年 8 月发布 1.0。

真正让 TS 爆发的不是语言特性,而是 DefinitelyTyped——一个社区维护的类型定义仓库。它让所有 JS 老库都能被 TS 使用,这是"类型系统孤岛"变为"覆盖整个生态"的关键一步。目前 @types/* 包有几千个,绝大多数常用库都能找到定义。

一条重要的历史教训:TS 的类型是结构化的(structural),不是名义化的(nominal)。两个接口只要形状一样就互相兼容,不需要继承关系。这与 Java / C# 完全不同,带来了灵活性(鸭子类型依旧可用),也带来了"看起来不像同一个东西,却可以互相赋值"的困惑。

interface Point2D { x: number; y: number }
interface Vector { x: number; y: number }

const p: Point2D = { x: 1, y: 2 };
const v: Vector = p;   // 合法。结构相同即可

类型系统的深度:它其实比你想的强

大多数人对 TS 的印象停在 string、number、interface,但它的类型层其实能做到编译期计算。这是它最被低估的部分。

字面量类型与联合类型

type Status = "pending" | "paid" | "shipped" | "done";

function next(s: Status): Status {
  switch (s) {
    case "pending": return "paid";
    case "paid":    return "shipped";
    case "shipped": return "done";
    case "done":    return "done";
  }
}
// 少写一个 case,函数会报"函数缺少结尾 return";写错一个拼写,直接报错

这一条就能消灭掉大半"状态字符串写错了"的生产事故。对比 enum,现在更推荐用联合字面量 + as const 对象,因为 enum 有运行时产物、行为诡异(数字枚举允许任意整数赋值)。

类型收窄(narrowing)

type Result =
  | { ok: true; data: string }
  | { ok: false; error: Error };

function handle(r: Result) {
  if (r.ok) {
    r.data;      // ✅ 这里有 data
    // r.error   // ❌ 不该能访问
  } else {
    r.error.message;  // ✅ 这里只有 error
  }
}

判别联合(discriminated union)是 TS 里最实用的模式,一个 ok 字段就把所有非法状态排除了。这比一堆可选字段的"胖对象"强太多。

泛型与条件类型

// 从数组元素类型推导
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;

// 从函数返回值推导
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;

// 把对象所有属性变成可选 + 保留字面量
type PartialKeep<T> = { [K in keyof T]?: T[K] };

// 实战里最有用的:过滤对象键
type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};

infer 和映射类型里用 as 重映射键——这两招在处理 API 响应、ORM 类型、Redux 状态时价值极高。如果只记一个 TS 技巧,记住"不要在函数参数上手动重复声明类型,让它从泛型推导"。

一个真实世界的例子:类型安全的 API 客户端

type Api = {
  "/users": { GET: { res: { id: number; name: string }[] } };
  "/orders": { POST: { body: { userId: number; items: string[] }; res: { orderId: string } } };
};

async function call<K extends keyof Api, M extends keyof Api[K]>(
  path: K,
  method: M,
  ...[body]: Api[K][M] extends { body: infer B } ? [B] : []
): Promise<Api[K][M] extends { res: infer R } ? R : never> {
  const r = await fetch(path, {
    method: method as string,
    body: body ? JSON.stringify(body) : undefined,
  });
  return r.json() as any;
}

const users = await call("/users", "GET");                 // ✅ 类型是 User[]
await call("/orders", "POST", { userId: 1, items: ["a"] }); // ✅ body 必须匹配
// await call("/orders", "POST");                           // ❌ 缺少 body,编译期报错

这段代码之所以有价值,是因为它把"接口契约"从文档变成了编译期约束。当然实际项目里会用 OpenAPI 生成器或 tRPC 做这件事,原理一致。

工程实践:怎么用才不会把自己坑死

1. tsconfig 的严格度决定一切

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  }
}

strict 只是起点。真正容易出问题的是 noUncheckedIndexedAccess(arr[0] 的类型变成 T | undefined,逼你处理越界)和 exactOptionalPropertyTypes({a?: string} 不允许显式赋值 undefined)。这两个开了会疼一阵,但不开会留坑。

2. 边界处必须校验,TS 给不了你运行时安全

// ❌ 类型断言等于自欺欺人,接口返回什么都信
const data = (await res.json()) as User;

// ✅ 边界用 schema 校验
import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  email: z.string().email(),
});

const user = UserSchema.parse(await res.json());  // 运行时真实校验,失败即抛
type User = z.infer<typeof UserSchema>;           // 类型自动推导,不用手写两遍

所有外部输入——HTTP 响应、表单、localStorage、环境变量、消息队列——都属于"类型系统的边界外"。TS 在这个位置没有任何保护能力,schema 校验是必须的。这一条能防住大量"字段缺失导致线上报 null 引用"的问题。

3. 别滥用断言

as any 每用一次,就等于在那段代码上关掉了类型检查,而且会传染(any 参与运算的结果还是 any)。项目里可以定一条规矩:as any 出现需要 code review 说明原因,CI 里用 eslint 的 @typescript-eslint/no-explicit-any 拦一道。

4. 版本选择

2026 年的情况:TS 5.x 系列稳定,--moduleResolution bundler 已成为主流配置,装饰器(TC39 Stage 3)已可用但实战中主要还是框架在用(NestJS、Angular)。TypeScript 7(原生编译器,用 Go 重写)已经在推进,官方宣称编译速度提升一个数量级——这会是过去几年里对开发体验影响最大的一次改动,大型仓库的 tsc 从分钟级降到秒级。

它的代价是什么

诚实地说几条:

  • 编译时间。超大仓库(几千个文件)全量 tsc 可能要几十秒到几分钟,必须靠项目引用(project references)和增量编译。
  • 类型体操过度。见过为了精确推导写 40 行 conditional type 的代码,别人看不懂,重构不敢动。类型复杂度也是技术债,能换简单的写法就换简单。
  • 配置心智负担。module / moduleResolution / target / lib 的组合让无数人踩坑,尤其配 ESM 的时候。
  • 不能保证运行时。这一条前面说过了,但它是所有误用的根源。

结论

TypeScript 不是一个"更安全的 JavaScript",它是一种工程治理手段:把代码约定从口头、文档、code review 里,搬到了编译器这个不会偷懒的检查者手里。它不解决所有问题,但能把"低级错误"这一类问题清零。对任何一个活过两年的项目来说,这笔账都是划算的。