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 里,搬到了编译器这个不会偷懒的检查者手里。它不解决所有问题,但能把"低级错误"这一类问题清零。对任何一个活过两年的项目来说,这笔账都是划算的。