很多前端同学对 TypeScript 的感情是复杂的:一方面,招聘要求里几乎人手一条“熟悉 TypeScript”;另一方面,真到了写业务的时候,any 一贴、as 一甩,类型检查器瞬间变成了一个昂贵的摆设。问题通常不在于“学不会语法”,而在于没有建立起类型系统的思维模型——不知道类型从哪里来、往哪里去,不知道什么时候该写、什么时候该让编译器自己推。本篇 50 分钟长文,从类型系统的本质讲起,沿着基础类型 → 类型收窄 → 接口与泛型 → 类型编程 → 工程配置 → 渐进迁移这条主线,把 TypeScript 真正用起来所需要的东西完整地过一遍。读完之后,你不一定马上能写出库级别的类型体操,但你一定能做到:让类型成为帮手,而不是负担。
一、为什么前端需要 TypeScript
先看一段几乎所有前端都写过的代码:
function getDisplayName(user) {
return user.profile.name.toUpperCase();
}
// 某处调用
getDisplayName({ id: 1, name: 'Alice' });
// 💥 TypeError: Cannot read properties of undefined (reading 'name')
这个错误有两个特点:第一,它发生在运行时;第二,它发生在离错误源头很远的地方。写这段代码的人和调用这段代码的人,可能根本不是同一个人,中间隔着三个月和两个版本迭代。JavaScript 的动态类型机制让这一切畅通无阻,直到用户在页面上点了一下,白屏了。
TypeScript 要解决的就是这个问题:把这类错误从“用户运行时”提前到“你按下保存的那一刻”。它不改变 JavaScript 的运行时行为,只是在代码进入浏览器之前,先让一个静态分析器把结构性的错误挑出来。
类型错误的成本曲线
| 发现阶段 | 发现者 | 修复成本 | 影响范围 |
|---|---|---|---|
| 编辑器里 | 你自己 | 几秒钟 | 一行代码 |
| 代码评审 | 同事 | 几分钟 | 一个 PR |
| 测试环境 | 测试同学 | 半小时 | 一个功能 |
| 线上监控 | 运维 / 用户 | 几小时起 | 一批用户 |
| 用户投诉 | 客服 | 按天计算 | 品牌与信任 |
这张表是 TypeScript 全部价值的来源:它把错误往成本曲线的左端推。越往左,同样的错误越便宜。
类型系统的四个维度
要理解 TypeScript 的定位,得先知道“类型系统”其实有四个独立的维度。点击下面的卡片展开说明:
1 + '1' 会报错。弱类型:允许隐式转换,1 + '1' 得到 '11'。JavaScript 属于弱类型,TypeScript 在编译期加上了强约束,但运行时的弱转换依然存在。TypeScript 的位置可以一句话概括:静态、编译期强约束、结构类型、强推断。理解了这四点,很多“为什么 TS 是这样设计的”问题就迎刃而解了。
TypeScript 不是什么
- 不是运行时保障。类型在编译后被完全擦除,浏览器拿到的还是普通 JavaScript。类型系统保护的是“你写代码的过程”,不是“用户使用产品的过程”。所以接口返回的数据该校验还是要校验。
- 不是万能的。一个
any就能让整条类型链断掉;一次不安全的as断言就能让编译器闭嘴。类型系统的强度,取决于团队愿意维持到什么程度。 - 不是 Java。不要因为它有
class、interface就照搬面向对象那一套。TypeScript 的类型是给 JavaScript 服务的,很多场景下用联合类型 + 收窄比用继承清晰得多。 - 不是只有大项目才需要。小项目同样会遇到“改了一个字段名,忘了改另一个文件”的问题,只是规模小、暴露得晚一点而已。
什么时候值得上 TypeScript
- 多人协作,接口边界多,改一处牵动多处
- 项目生命周期超过半年,需要长期维护
- 核心业务逻辑复杂,状态组合多
- 要对外提供 SDK 或组件库,需要类型提示
- 团队已经踩过“字段名写错”的坑
- 一次性脚本、临时 Demo、验证想法
- 团队里没人愿意维护类型定义,最后满地
any - 没有构建流程,直接靠
<script>引入 - 项目即将下线,只做少量修补
二、从注解到推断:类型的两种来源
TypeScript 里的类型信息有两个来源:你手写的类型注解,和编译器自动推导的类型。初学者最常见的误区是“什么都要标一遍”,结果代码里充斥着冗余的类型声明,改起来比 JavaScript 还累。
const name: string = 'Alice';
const count: number = 0;
const isActive: boolean = true;
const list: string[] = ['a', 'b'];
// ✅ 让推断干活
const name = 'Alice';
const count = 0;
const isActive = true;
const list = ['a', 'b'];
function greet(name: string): string {
return `Hello, ${name}`;
}
// ✅ 该写的地方:对象的显式契约
interface User {
id: number;
name: string;
email?: string;
}
// ✅ 该写的地方:空数组 / null 初始值
const items: Item[] = [];
一条实用的经验法则:类型注解写在“边界”上。函数参数、函数返回值、模块导出的对象、类的公开成员——这些是别人依赖你的地方。函数体内部的局部变量,绝大多数时候让编译器推断就好。
2.1 基础类型一览
let s: string = '文本';
let n: number = 42; // 整数、小数、NaN、Infinity 都算
let b: boolean = true;
let big: bigint = 9007199254740991n;
let sym: symbol = Symbol('key');
let u: undefined = undefined;
let nul: null = null;
// 数组:两种写法等价
let arr1: number[] = [1, 2, 3];
let arr2: Array<string> = ['a', 'b'];
let arr3: readonly number[] = [1, 2]; // 只读数组
// 元组:长度与类型都固定的数组
let point: [number, number] = [10, 20];
let entry: [string, number, boolean?] = ['a', 1];
// 对象类型
let user: { id: number; name: string; email?: string } = {
id: 1,
name: 'Alice',
};
// 函数类型
let handler: (event: MouseEvent) => void;
let compare: (a: number, b: number) => number = (a, b) => a - b;
2.2 any、unknown、never、void:四个容易搞混的类型
| 类型 | 含义 | 能赋值给别人吗 | 别人能赋值给它吗 | 建议 |
|---|---|---|---|---|
any |
放弃检查 | 能 | 能 | 尽量避免,是类型系统的漏洞 |
unknown |
未知但安全 | 不能 | 能 | 外部输入的首选类型 |
never |
不可能出现的值 | 能 | 不能 | 穷尽性检查、抛错函数 |
void |
函数没有返回值 | 部分 | 能 | 回调、事件处理器 |
unknown 是 any 的安全替代品。它同样能接收任何值,但在使用之前必须先收窄类型:
function bad(data: any) {
return data.user.name; // 运行时可能直接崩
}
// ✅ unknown:必须先证明它是什么
function good(data: unknown) {
if (typeof data !== 'object' || data === null) {
throw new Error('期望一个对象');
}
if (!('user' in data)) {
throw new Error('缺少 user 字段');
}
return (data as { user: { name: string } }).user.name;
}
never 最常见的用途是穷尽性检查:当你把所有可能的分支都处理完,剩下的那个变量类型会自动变成 never。如果哪天有人往联合类型里加了一个新成员,编译器就会在 never 那一行报错,提醒你“还有分支没处理”。
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'rect'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
case 'rect':
return shape.width * shape.height;
default: {
// 如果漏了某个分支,这里会编译报错
const exhaustive: never = shape;
throw new Error(`未处理的形状:${exhaustive}`);
}
}
}
2.3 字面量类型与 as const
TypeScript 允许把具体的值当成类型使用,这是构建联合类型的基础:
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;
let dir: Direction = 'up';
dir = 'north'; // ❌ 类型错误
// as const 把整个对象变成只读的字面量类型
const config = {
host: 'localhost',
port: 8080,
protocol: 'http',
} as const;
// config.host 的类型是 'localhost',不是 string
// config.port 的类型是 8080,不是 number
// 整个对象是 readonly 的,无法修改
// 经典用法:从常量数组推导联合类型
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number];
// 等价于 type Role = 'admin' | 'editor' | 'viewer'
(typeof ROLES)[number] 这个写法值得记住:它是“从值反推类型”的标准姿势。以后要新增角色,只需要往数组里加一项,类型自动更新,再也不用维护两个地方。
三、类型收窄:让联合类型变得可控
联合类型是 TypeScript 最强大的特性之一,但它带来一个问题:string | number 上面既没有 padStart 也没有 toFixed,因为编译器不确定你现在拿到的是哪一种。解决办法是收窄(Narrowing)——用运行时的检查,让编译器在某个分支里确定类型。
点击下面的按钮,看看各种收窄方式的实际效果:
if (typeof value === 'string') {
// 此分支内 value: string
return value.padStart(2, '0');
}
// 此分支内 value: number
return value.toFixed(2);
}
typeof 能区分 string、number、boolean、symbol、bigint、undefined、function 和 object。注意 typeof null === 'object' 这个历史遗留问题,判断对象前一定要先排除 null。
if (value instanceof Date) {
// 此分支内 value: Date
return value.toISOString();
}
// 此分支内 value: string
return value.trim();
}
instanceof 依赖原型链,适合类实例的判断。它无法区分接口和类型别名,因为那些在运行时根本不存在。Array.isArray() 则是判断数组的推荐方式。
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ('swim' in animal) {
animal.swim(); // animal: Fish
} else {
animal.fly(); // animal: Bird
}
}
in 判断属性是否存在,适合区分“形状不同”的对象类型。它的前提是两个类型没有共同的属性名,否则无法有效区分。
type Result<T> =
| { status: 'ok'; data: T }
| { status: 'error'; message: string }
| { status: 'loading' };
function render<T>(result: Result<T>) {
switch (result.status) {
case 'ok': return result.data;
case 'error': return result.message;
case 'loading': return null;
}
}
判别联合(Discriminated Union)是 TypeScript 里最重要的建模手法之一。它的核心是给每个成员加一个取值唯一的字面量字段,然后通过这个字段做 switch。状态机、API 响应、事件对象、Redux Action,全都适合用这种方式表达。
if (name) {
// 此分支内 name: string(排除了 undefined 和 null)
return `Hello, ${name.toUpperCase()}`;
}
return 'Hello, guest';
}
// ⚠️ 注意:真值收窄会一并排除 ''、0、false
function bad(count?: number) {
if (count) {
// count = 0 时不会进入这里!
return count + 1;
}
return 0;
}
真值收窄很简洁,但会“误伤” 0、''、false 这些合法值。当这些值在你的业务里是有效数据时,应该显式写 count !== undefined,而不是依赖真值判断。
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isUser(value: unknown): value is { id: number; name: string } {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
);
}
// 类型谓词可以直接用在 filter 里,自动收窄数组元素类型
const mixed: (string | number)[] = ['a', 1, 'b'];
const onlyStrings = mixed.filter(isString);
// onlyStrings 的类型是 string[],而不是 (string | number)[]
类型谓词是处理外部数据(接口响应、JSON.parse 结果、localStorage 内容)的利器。它的价值不只是“骗过编译器”,而是把校验逻辑集中到一个可复用的函数里——校验和类型声明同步维护,不会脱节。
断言函数:另一种收窄方式
类型谓词返回布尔值,断言函数则直接抛错,适合“不满足条件就中断”的场景:
if (typeof value !== 'string') {
throw new TypeError(`期望 string,实际得到 ${typeof value}`);
}
}
function process(input: unknown) {
assertIsString(input);
// 从这里开始,input 就是 string 了
return input.trim();
}
收窄方式的适用场景
| 方式 | 适用 | 注意 |
|---|---|---|
typeof |
原始类型、函数 | typeof null === 'object' |
instanceof |
类实例、内置对象 | 跨 iframe 会失效 |
in |
形状不同的对象 | 需要没有同名属性 |
| 判别字段 | 联合类型建模 | 字段值必须唯一 |
| 真值判断 | 排除 null / undefined | 会误伤 0、''、false |
| 类型谓词 | 复杂对象校验 | 逻辑要真的正确,编译器不会验证 |
| 断言函数 | 参数校验、守卫 | 会抛异常,注意调用位置 |
四、interface 与 type:两种声明方式的分工
这是初学者最常问的问题之一:“到底该用 interface 还是 type?”简短的回答是:大部分场景下两者可以互换,但有几处关键差异决定了选择。
interface User {
id: number;
name: string;
email?: string;
readonly createdAt: Date;
}
// 声明合并:同名 interface 会自动合并
interface User {
avatar?: string;
}
// 最终 User 拥有 id/name/email/createdAt/avatar
type ID = string | number;
type Point = [number, number];
type Handler = (e: Event) => void;
type Nullable<T> = T | null;
// 组合与运算
type UserWithRole = User & { role: 'admin' | 'user' };
type Keys = keyof User;
| 能力 | interface | type |
|---|---|---|
| 描述对象形状 | 可以 | 可以 |
| 描述联合类型 | 不行 | 可以 |
| 描述元组 | 不行 | 可以 |
| 声明合并 | 可以 | 不行 |
| 被类 implements | 可以 | 可以(对象形状) |
| extends / 交叉 | extends | & |
| 条件类型中使用 | 受限 | 自由 |
| 错误提示可读性 | 显示名字 | 可能展开 |
选择建议
对象类型的进阶写法
interface StringMap {
[key: string]: string;
}
// 同时限定已知键和未知键
interface Headers {
contentType: string;
authorization?: string;
[key: string]: string | undefined;
}
// 只读数组与只读元组
type ImmutableList = readonly string[];
type Pair = readonly [number, number];
// 函数重载:同一个函数多种签名
function createElement(tag: 'input'): HTMLInputElement;
function createElement(tag: 'div'): HTMLDivElement;
function createElement(tag: string): HTMLElement;
function createElement(tag: string): HTMLElement {
return document.createElement(tag);
}
注意重载的写法的两个要点:重载签名在前,实现签名在后,而且实现签名必须兼容所有重载签名。调用方只能看到重载签名,看不到实现签名。
五、泛型:让类型也能当参数
泛型是 TypeScript 从“给代码加标签”升级到“可复用的类型逻辑”的分水岭。它的本质是:把类型本身变成一个参数,让调用方决定具体是什么。
function firstAny(arr: any[]): any {
return arr[0];
}
// ✅ 用泛型:类型随输入自动推导
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const a = first([1, 2, 3]); // number | undefined
const b = first(['x', 'y']); // string | undefined
const c = first<boolean>([]); // 也可以显式指定
5.1 泛型约束:限制 T 的范围
裸的 T 什么属性都访问不了。如果函数体里需要用到某个属性,就必须告诉编译器“T 至少要有这些”:
function longest<T>(a: T, b: T) {
return a.length > b.length ? a : b;
}
// ✅ 用 extends 约束
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
longest('hello', 'hi'); // string
longest([1, 2, 3], [1]); // number[]
longest(1, 2); // ❌ number 没有 length
// keyof 约束:K 必须是 T 的键
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Alice' };
get(user, 'name'); // string
get(user, 'id'); // number
get(user, 'age'); // ❌ 'age' 不在 keyof typeof user 中
get 这个函数是 TypeScript 类型推导的一个小高峰:返回值类型 T[K] 会根据传入的 key 自动变化。传 'name' 得到 string,传 'id' 得到 number。这种“类型跟着值走”的能力,是让 API 变得好用又安全的关键。
5.2 默认类型参数
success: boolean;
data: T;
error: E | null;
}
type A = ApiResponse; // data: unknown
type B = ApiResponse<User>; // data: User
type C = ApiResponse<User, ErrorCode>; // 两个都指定
5.3 泛型在函数式工具中的应用
function groupBy<T, K extends string | number>(
list: T[],
getKey: (item: T) => K,
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of list) {
const key = getKey(item);
(result[key] ??= []).push(item);
}
return result;
}
const orders = [
{ id: 1, status: 'paid', amount: 100 },
{ id: 2, status: 'pending', amount: 200 },
{ id: 3, status: 'paid', amount: 300 },
];
const byStatus = groupBy(orders, (o) => o.status);
// Record<'paid' | 'pending', Order[]>
// 注意:K 被推导成了字面量联合类型,而不是宽泛的 string
这里有一个细节值得留意:因为 orders 是 const 声明、且 status 字段被推导为字面量联合类型,所以 K 最终是 'paid' | 'pending' 而不是 string。这意味着 byStatus.paid 是合法的,而 byStatus.refunded 会报错——类型把业务事实也编码进去了。
5.4 泛型的常见误区
function log<T>(value: T): void —— T 只出现一次,没有建立任何关系,等价于 function log(value: unknown): void。泛型的意义在于建立输入与输出之间的关系。
function identity<T>(value: T): T —— 输入什么类型,输出就是什么类型。或者 function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>,输入决定输出。
<A, B, C, D> 五个单字母参数,三个月后没人记得谁是谁。单个参数用 T 没问题,多个参数应该用有意义的名字。
<TData, TError>、<TInput, TOutput>、<TKey, TValue>。读签名就能知道每个类型参数代表什么。
六、内置工具类型:不用重复造轮子
TypeScript 标准库自带了一批工具类型(Utility Types),它们本身就是用条件类型和映射类型实现的。熟练使用它们,可以省掉大量重复定义。
| 工具类型 | 作用 | 典型用法 |
|---|---|---|
Partial<T> |
所有属性变可选 | 更新接口的入参 |
Required<T> |
所有属性变必填 | 校验完成后的数据 |
Readonly<T> |
所有属性只读 | 常量配置、冻结状态 |
Pick<T, K> |
挑选部分属性 | 列表项只暴露少数字段 |
Omit<T, K> |
排除部分属性 | 创建时去掉 id |
Record<K, V> |
构造键值映射 | 枚举到文案的映射表 |
Exclude<T, U> |
从联合中排除 | 剔除某些字面量 |
Extract<T, U> |
从联合中提取 | 只保留函数类型 |
NonNullable<T> |
去掉 null / undefined | 校验后的数据 |
ReturnType<F> |
取函数返回值类型 | 复用已有函数的返回类型 |
Parameters<F> |
取函数参数类型元组 | 包装函数时转发参数 |
Awaited<T> |
递归解开 Promise | 取 async 函数的实际返回类型 |
实战:一个完整的用户模块类型定义
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
updatedAt: string;
}
// 创建:去掉服务端生成的字段
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// 更新:全部可选,但不允许改 id
type UpdateUserInput = Partial<Omit<User, 'id'>>;
// 列表项:只暴露列表需要的字段
type UserListItem = Pick<User, 'id' | 'name' | 'role'>;
// 角色文案映射:键必须覆盖所有角色,值必须是字符串
const ROLE_LABEL: Record<User['role'], string> = {
admin: '管理员',
editor: '编辑',
viewer: '访客',
// 如果 User 新增了角色,这里会立刻报错 —— 这正是我们想要的
};
// 从已有函数推导类型,避免重复声明
async function fetchUser(id: number): Promise<User> { ... }
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// FetchedUser 就是 User
ROLE_LABEL 这个例子值得反复体会:用 Record<User['role'], string> 声明映射表,等于把“角色列表”和“文案表”绑在了一起。以后新增角色,如果忘了加文案,编译器会直接报错。这就是类型系统在“防遗漏”上的价值。
自定义工具类型
type Optional<T, K extends keyof T> =
Omit<T, K> & Partial<Pick<T, K>>;
// 把指定字段变成必填
type RequireFields<T, K extends keyof T> =
T & Required<Pick<T, K>>;
// 深层只读
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
};
// 取出数组中元素的类型
type ElementOf<T extends readonly unknown[]> = T[number];
// 用法
type Users = User[];
type SingleUser = ElementOf<Users>; // User
七、条件类型、映射类型与模板字面量类型
如果说泛型是“类型可以当参数”,那么这一节讲的就是“类型可以参与运算”。这是 TypeScript 类型系统最灵活、也最容易失控的部分。
7.1 条件类型
条件类型的语法和三元表达式一样:T extends U ? X : Y。含义是“如果 T 可以赋值给 U,那么结果是 X,否则是 Y”。
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
// 取出 Promise 的内部类型(简化版 Awaited)
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type C = Unwrap<Promise<number>>; // number
type D = Unwrap<string>; // string
7.2 infer:在条件类型里“提取”类型
infer 是条件类型的关键字,它允许你在模式匹配中声明一个待推导的类型变量:
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;
// 提取函数第一个参数
type FirstParam<T> = T extends (first: infer F, ...rest: never[]) => unknown ? F : never;
// 提取数组元素
type ItemType<T>> = T extends (infer I)[] ? I : never;
// 提取对象某个属性的类型
type PropType<T, K> = K extends keyof T ? T[K] : never;
// 提取数组最后一个元素
type Last<T extends unknown[]> =
T extends [...unknown[], infer L] ? L : never;
type E = Last<[string, number, boolean]>; // boolean
7.3 分发式条件类型
当条件类型作用在裸类型参数上、且传入的是联合类型时,它会自动分发到每个成员:
type R1 = ToArray<string | number>;
// 分发后:string[] | number[]
// 而不是:(string | number)[]
// 阻止分发:用方括号包起来
type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
type R2 = ToArrayNoDist<string | number>;
// (string | number)[]
// 实用例子:只保留字符串键
type StringKeys<T> = {
[K in keyof T]: K extends string ? K : never;
}[keyof T];
分发行为是很多内置工具类型的实现基础,比如 Exclude<T, U> 的定义就是 T extends U ? never : T——正因为分发的存在,它才能逐个过滤联合类型中的成员。
7.4 映射类型
映射类型让你“遍历一个类型的所有属性,逐个变换”:
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
interface User { id: number; name: string }
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }
// 去掉所有可选修饰符
type Concrete<T> = {
[K in keyof T]-?: T[K];
};
// 去掉所有只读修饰符
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// 从事件定义生成处理器类型
interface Events {
click: { x: number; y: number };
change: { value: string };
}
type Handlers = {
[K in keyof Events as `on${Capitalize<K & string>}`]: (payload: Events[K]) => void;
};
// { onClick: (payload: {x, y}) => void; onChange: (payload: {value}) => void }
as 子句(键重映射)是 TypeScript 4.1 引入的能力,它让映射类型可以对键本身做变换。Capitalize、Uppercase、Lowercase、Uncapitalize 是四个内置的字符串操作工具类型。
7.5 模板字面量类型
这是类型系统里最“魔法”的部分:类型层面也能做字符串拼接。它让 API 的类型定义可以精确到字符串的内容。
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'
// 解析路由参数
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<'/user/:userId/post/:postId'>;
// 'userId' | 'postId'
// 类型安全的 CSS 单位
type CSSUnit = 'px' | 'rem' | 'em' | '%' | 'vh' | 'vw';
type Length = `${number}${CSSUnit}` | 'auto' | '0';
const width: Length = '100px'; // ✅
const height: Length = '100'; // ❌ 缺少单位
const margin: Length = '2rem'; // ✅
- 事件名、路由、i18n key 这类有固定格式的字符串
- CSS 单位、颜色、尺寸等需要约束格式的值
- 从已有类型生成派生类型(如 getter 名)
- 为了炫技而写三层嵌套的字符串解析
- 把类型报错信息变得完全无法阅读
- 编译器明显变慢(类型体操是编译时的开销)
八、函数、异步与错误处理
8.1 函数类型的完整写法
function request(
url: string,
method: 'GET' | 'POST' = 'GET',
timeout?: number,
): Promise<unknown> { ... }
// 剩余参数
function log(level: 'info' | 'warn', ...messages: unknown[]) { ... }
// 解构参数的类型
function createUser({
name,
age = 0,
email,
}: {
name: string;
age?: number;
email?: string;
}) { ... }
// 函数类型别名,便于复用
type Comparator<T> = (a: T, b: T) => number;
const byAge: Comparator<User> = (a, b) => a.age - b.age;
注意一个细节:可选参数必须排在必填参数后面。如果确实需要“中间可选”,应该显式传入 undefined,或者改用对象参数——后者在参数超过三个时通常更清晰。
8.2 回调与 this 类型
function handleClick(this: HTMLElement, event: MouseEvent) {
// this 被声明为 HTMLElement
this.classList.add('active');
}
// 类字段箭头函数:this 永远指向实例
class Counter {
count = 0;
increment = () => { this.count++; };
}
// 泛型 this 类型:支持链式调用并保留子类类型
class Builder {
private parts: string[] = [];
add(part: string): this {
this.parts.push(part);
return this;
}
build(): string {
return this.parts.join('-');
}
}
class UrlBuilder extends Builder {
protocol(p: string): this {
this.parts.unshift(p + '://');
return this;
}
}
// 用 this 类型,链式调用不会丢失子类信息
new UrlBuilder().protocol('https').add('example.com').build();
// .protocol 之后返回的仍是 UrlBuilder,而不是 Builder
8.3 异步与 Promise 类型
async function loadUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// Promise.all 保留元组结构
const [user, posts] = await Promise.all([
loadUser(1), // Promise<User>
loadPosts(1), // Promise<Post[]>
]);
// user: User, posts: Post[] —— 自动推导,不需要手动断言
// Promise.allSettled 的结果类型
const results = await Promise.allSettled([loadUser(1)]);
for (const r of results) {
if (r.status === 'fulfilled') {
console.log(r.value); // User
} else {
console.error(r.reason); // any
}
}
8.4 错误处理中的 unknown
在 strict 模式下,catch 到的变量类型是 unknown(如果显式标注为 any 或 unknown,默认在 useUnknownInCatchVariables 开启时是 unknown)。这意味着你不能直接访问它的属性——这是好事,因为抛出的东西真的可能是任何值。
try {
await saveData();
} catch (err) {
console.log(err.message); // ❌ err 是 unknown
}
// ✅ 先收窄,再使用
function toError(value: unknown): Error {
if (value instanceof Error) return value;
if (typeof value === 'string') return new Error(value);
return new Error('未知错误');
}
try {
await saveData();
} catch (err) {
const error = toError(err);
console.error(error.message);
}
8.5 结果类型:让错误进入类型系统
另一种常见的风格是不抛异常,而是返回一个显式的结果对象。这样“可能失败”就成了类型的一部分,调用方必须处理:
| { ok: true; value: T }
| { ok: false; error: E };
async function safeLoad(id: number): Promise<Result<User>> {
try {
const user = await loadUser(id);
return { ok: true, value: user };
} catch (err) {
return { ok: false, error: toError(err).message };
}
}
const result = await safeLoad(1);
if (result.ok) {
console.log(result.value.name); // 收窄为 User
} else {
console.error(result.error); // 收窄为 string
}
这种风格的优点是错误不可能被静默忽略——因为 result.ok 是 false 时根本访问不到 value。代价是每一层都要多写几行包装代码。它适合错误是“预期内业务分支”的场景,而不是真正的异常。
九、类、修饰符与面向对象
TypeScript 给 JavaScript 的 class 加上了访问修饰符、抽象类、参数属性等能力。但要提醒一句:不要因为有了这些工具就把前端代码写成 Java。组合通常优于继承,函数通常优于类。
9.1 访问修饰符
public owner: string; // 默认,谁都能访问
private balance: number = 0; // 只有类内部能访问
protected currency = 'CNY'; // 类与子类可访问
readonly openedAt = new Date(); // 只能在构造函数中赋值
// 真正的私有字段(ECMAScript 标准)
#secret = 'internal';
// 参数属性:把参数声明和赋值合并
constructor(
public id: number,
private name: string,
) {
this.owner = name;
}
deposit(amount: number): void {
this.balance += amount;
}
get currentBalance(): number {
return this.balance;
}
}
const account = new BankAccount(1, 'Alice');
account.deposit(100);
account.currentBalance; // 100
account.balance; // ❌ private
private 和 # 的区别值得说清楚:private 只在类型检查阶段生效,编译后的 JavaScript 里这个字段依然是公开的;而 # 是真正的运行时私有,编译产物里也会保持私有。如果只是想让类型系统拦住误用,private 就够了;如果涉及安全敏感的数据,应该用 #。
9.2 抽象类与接口实现
abstract class StorageAdapter {
abstract read(key: string): Promise<string | null>;
abstract write(key: string, value: string): Promise<void>;
// 通用逻辑写在基类,子类只需要实现读写
async readJSON<T>(key: string): Promise<T | null> {
const raw = await this.read(key);
return raw ? JSON.parse(raw) : null;
}
async writeJSON<T>(key: string, value: T): Promise<void> {
await this.write(key, JSON.stringify(value));
}
}
class LocalStorageAdapter extends StorageAdapter {
async read(key: string) {
return localStorage.getItem(key);
}
async write(key: string, value: string) {
localStorage.setItem(key, value);
}
}
// 注意:抽象类不能被实例化
new StorageAdapter(); // ❌
9.3 接口实现与结构化类型
TypeScript 是结构类型系统,这意味着一个类不需要显式声明“我实现了某个接口”,只要形状对得上就行。这在包装第三方对象时特别有用,但也会带来一些意外:
interface Named { name: string }
function draw(point: Point) { ... }
// 多出来的字段在“直接传入字面量”时会报错
draw({ x: 1, y: 2, z: 3 }); // ❌ 对象字面量多余属性检查
// 但通过变量传入就不会
const p = { x: 1, y: 2, z: 3 };
draw(p); // ✅ 合法
// 空接口 / 空对象类型会接受任何非空值,慎用
function acceptAnything(value: {}) { ... }
acceptAnything('string'); // ✅ 能通过
// 真正的“任何非 null / undefined 值”应该用 unknown 或 object
9.4 声明合并
接口可以同名合并,这在扩展第三方类型时非常有用:
declare global {
interface Window {
__APP_VERSION__: string;
__DEV__: boolean;
dataLayer: unknown[];
}
}
// 扩展第三方库的类型
declare module 'express' {
interface Request {
user?: { id: string; role: string };
}
}
9.5 类还是函数?
- 需要维护内部状态,并且状态与行为强绑定
- 需要创建多个同类实例
- 需要实现某个明确的接口(如适配器)
- 需要利用
private/#封装不变量
- 无状态的数据转换、格式化、校验
- 只有一两个方法的“工具对象”
- React 组件(函数组件是主流)
- 只是想把一组常量放在一起
十、模块、声明文件与第三方库
10.1 导入导出的类型写法
import type { User, ApiResponse } from './types';
// 混合导入
import { fetchUser, type User } from './api';
// 只导出类型
export type { User };
export type Status = 'idle' | 'loading';
// 三斜线指令(较少用,一般出现在 .d.ts 顶部)
/// <reference types="node" />
// 导入 JSON(需要 resolveJsonModule)
import config from './config.json';
// 动态导入返回 Promise<模块类型>
const { default: Chart } = await import('chart.js');
import type 有一个容易被忽略的好处:它能防止循环依赖。因为纯类型导入在编译后会被删除,所以“A 导入 B 的类型、B 导入 A 的类型”这种循环,在运行时并不会真的形成环。
10.2 声明文件(.d.ts)
当你要为一个没有类型的 JavaScript 库写类型时,就需要声明文件。它的规则是:只写类型,不写实现。
declare module 'legacy-lib' {
export interface Options {
debug?: boolean;
timeout?: number;
}
export function init(options?: Options): void;
export function track(event: string, payload?: Record<string, unknown>): void;
const _default: { init: typeof init; track: typeof track };
export default _default;
}
// 声明一个没有类型的模块(逃生舱,不推荐长期使用)
declare module 'untyped-package';
// 声明全局变量
declare const VERSION: string;
declare function gtag(...args: unknown[]): void;
// 声明资源模块(让 import './x.css' 有类型)
declare module '*.css' {
const classes: Record<string, string>;
export default classes;
}
declare module '*.svg' {
const url: string;
export default url;
}
10.3 @types 与类型来源优先级
当你 import 一个库却报“找不到类型声明”时,TypeScript 会按下面的顺序找类型:
package.json 里有 types 或 typings 字段,或目录下有 index.d.ts
@types/xxx,一般通过 npm i -D @types/xxx 安装
*.d.ts,通过 typeRoots 或 include 被加载
noImplicitAny 下会提示“Could not find a declaration file”
现在大多数主流库(React、Vue、axios、lodash、zod)都已经自带类型,只有历史包袱较重的老库才需要 @types。如果某个库确实没有类型,优先去社区看看有没有 @types 包;实在没有,再考虑自己写声明。
10.4 模块增强与全局增强
import 'axios';
declare module 'axios' {
export interface AxiosRequestConfig {
skipAuth?: boolean;
traceId?: string;
}
}
// 扩展 CSS 变量类型(配合模板字面量类型)
declare namespace CSS {
interface Properties {
'--brand-color'?: string;
'--spacing-unit'?: '4px' | '8px' | '12px';
}
}
十一、tsconfig:把编译器调成你想要的样子
tsconfig.json 是 TypeScript 项目的总控台。下面是一份适用于现代前端项目的配置,每一条都有必要存在的理由。
"compilerOptions": {
/* ===== 语言与输出 ===== */
"target": "ES2022", // 输出语法版本
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
/* ===== 严格模式(建议全开) ===== */
"strict": true,
"noUncheckedIndexedAccess": true, // 索引访问结果自动带上 undefined
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
/* ===== 代码质量 ===== */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"allowUnreachableCode": false,
/* ===== 互操作 ===== */
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
/* ===== 生成产物 ===== */
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true, // 交给打包器输出,tsc 只做检查
/* ===== 路径别名 ===== */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"]
}
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["node_modules", "dist"]
}
关键的严格选项解释
| 选项 | 开启后的效果 | 建议 |
|---|---|---|
strict |
一次性打开下面所有严格检查 | 新项目必开 |
strictNullChecks |
null 和 undefined 不再属于所有类型 |
必开 |
noImplicitAny |
禁止隐式 any |
必开 |
strictFunctionTypes |
函数参数逆变检查更严格 | 必开 |
noUncheckedIndexedAccess |
obj[key] 结果带上 undefined |
推荐,但改动量大 |
exactOptionalPropertyTypes |
age?: number 不允许显式传 undefined |
谨慎开启 |
skipLibCheck |
跳过 .d.ts 的类型检查 |
建议开启(提速) |
noUncheckedIndexedAccess 值得单独说一句:开启后,arr[0] 的类型会从 T 变成 T | undefined。这会让很多现有代码报错,但它确实反映了真相——数组索引真的可能越界。如果项目已经稳定,可以分阶段开启;如果正在重构,建议直接开。
多环境配置拆分
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
// 应用代码用一套,构建脚本用另一套
// 好处:Vite 配置文件的 Node 类型不会污染前端代码
十二、从 JavaScript 渐进迁移到 TypeScript
“我们项目三万行 JavaScript,不可能停下来重写一遍。”——这是最现实的顾虑。好消息是,TypeScript 从设计之初就支持渐进式迁移,你完全可以让 .js 和 .ts 长期共存。
第一阶段:只做检查,不改代码
npm i -D typescript
// 2. 生成配置
npx tsc --init
// 3. 允许 JS 文件参与编译,但先不做检查
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"noEmit": true
}
}
// 4. 先只对新增文件做检查
// 在 CI 里跑 tsc --noEmit,失败不阻塞合并,只做提示
第二阶段:逐个文件改名
迁移的顺序很重要。建议从依赖最少的叶子模块开始,逐步往上层推进:
第三阶段:逐步收紧
// 第一步:打开 allowJs + checkJs,暴露问题清单
// 第二步:给高频使用的模块补类型声明
// 第三步:打开 noImplicitAny,消灭隐式 any
// 第四步:打开 strictNullChecks,处理空值分支
// 第五步:打开完整 strict
// 临时逃生舱:只对某个文件放宽
// @ts-nocheck —— 整个文件跳过检查(尽量别用)
// @ts-ignore —— 忽略下一行的错误
// @ts-expect-error —— 期望下一行报错(比 ignore 好,因为错误消失时会提醒你)
@ts-expect-error 比 @ts-ignore 更值得推荐:当底层的 bug 被修复、这行代码不再报错时,@ts-expect-error 会反过来报错提醒你“这个抑制注释已经没用了,删掉它”。它自带清理机制。
第四阶段:让类型成为约束
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && vite build"
}
}
// CI 里把它设为必过项,类型错误不允许合并
// 同时配置 lint 规则,禁止新增 any
迁移中的常见阻力与应对
| 阻力 | 原因 | 应对 |
|---|---|---|
| 报错太多,无从下手 | 一次性开了太多严格选项 | 分批开启,先跑通构建 |
| 第三方库没类型 | 老库缺少 .d.ts |
先写最小声明(declare module 'x'),后续补全 |
| 改一处错一片 | 类型定义不准确,牵连太广 | 从叶子模块自下而上改 |
| 团队不配合 | 写类型增加了当下的工作量 | 先在小范围试点,用“少写了多少次调试”说话 |
| 到处是 any | 赶进度时的妥协 | ESLint 加 no-explicit-any,配合 unknown |
十三、常见陷阱与反模式
TypeScript 用不好,比不用更糟——因为它会给人一种“已经有类型保护了”的错觉。下面这些坑,几乎每个团队都踩过。
陷阱一:any 的传染
一个 any 会沿着调用链一路传播。你从接口里 any 出来一个值,传给函数 A,A 返回 any,再传给 B……最后整个数据流都失去了类型。
const data: any = await res.json();const name = data.user.name;const upper = name.toUpperCase();—— 编译期一路绿灯,运行时可能到处是坑。
const raw: unknown = await res.json();if (!isUserResponse(raw)) throw new Error('响应格式错误');const name = raw.user.name; // 类型安全—— 校验逻辑可复用,类型与运行时一致。
陷阱二:滥用类型断言
const user = data as User;
// ✅ 先校验,再断言
if (isUser(data)) {
const user = data; // 已经收窄,不需要断言
}
// ⚠️ 双断言绕过检查:几乎总是错的
const n = 'abc' as unknown as number;
// ✅ 如果确实需要断言,用 satisfies 保留检查
const config = {
host: 'localhost',
port: 8080,
} satisfies Record<string, string | number>;
// config 保留了字面量类型,同时验证了形状
satisfies 是 TypeScript 4.9 引入的运算符,它解决了一个长期痛点:既要验证对象符合某个类型,又要保留对象本身的精确推断。用 as 会丢掉精确类型,用类型注解会丢掉字面量,satisfies 两者兼得。
陷阱三:非空断言滥用
const el = document.getElementById('app')!;
el.innerHTML = '';
// ✅ 显式处理不存在的情况
const el = document.getElementById('app');
if (!el) {
throw new Error('找不到 #app 容器');
}
el.innerHTML = '';
// ✅ 或者用自定义断言函数集中处理
function requireElement<T extends Element>(selector: string): T {
const el = document.querySelector<T>(selector);
if (!el) throw new Error(`找不到元素:${selector}`);
return el;
}
陷阱四:过度使用枚举
enum Status {
Idle,
Loading,
Success,
Error,
}
// 编译后是一个双向映射对象,体积不小
// ✅ 字面量联合类型:零运行时开销
type Status = 'idle' | 'loading' | 'success' | 'error';
// ✅ 需要常量表时用 as const 对象
const STATUS = {
IDLE: 'idle',
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
// 既有常量表可用,又有联合类型可标注,还不会生成多余代码
陷阱五:类型体操过度
类型体操写起来很爽,但它的代价是:编译变慢、报错信息变得不可读、团队成员看不懂。判断标准很简单:如果一个类型定义需要写注释才能让人看懂,那它可能已经过度了。
type DeepMerge<A, B, K extends keyof B = keyof B> = { [P in keyof (A & B)]: P extends keyof A ? P extends keyof B ? A[P] extends object ? B[P] extends object ? DeepMerge<A[P], B[P]> : B[P] : B[P] : A[P] : B[P] }—— 报错时你会看到一屏的展开信息,完全找不到问题在哪。
type MergedConfig = AppConfig & Partial<RuntimeConfig>;用交叉类型和内置工具类型解决问题,报错信息清晰,任何人都能看懂。
陷阱六:把类型当成运行时校验
interface User { id: number; name: string }
const user: User = await res.json();
// 如果服务端返回的是 { id: '1', name: null },这里不会报错
// ✅ 运行时校验 + 类型推断,两者一致
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email().optional(),
});
type User = z.infer<typeof UserSchema>;
// 类型从 schema 推导,永远不会脱节
const user = UserSchema.parse(await res.json());
// 校验失败会抛错,成功则得到可信的 User
这条是本文最重要的一条建议:凡是从外部进入系统的数据,都应该在运行时校验一次。这些边界包括:HTTP 响应、localStorage、postMessage、URL 参数、表单输入、第三方 SDK 回调。类型系统管不到这些地方,只有运行时代验才能保证数据真的符合预期。
陷阱七:文件命名与模块解析的坑
import './User' 在本地没问题,到 Linux CI 就炸
moduleResolution: "Bundler" 时不用写扩展名,Node 的 ESM 则必须写 .js
import type 打破,值之间的循环需要重构
tsconfig 里的 paths 只影响类型检查,打包器需要单独配置
十四、实战:给一个真实的数据模块加上类型
下面是一段典型的前端数据获取代码,用 JavaScript 写成。它的问题很常见:字段名靠记忆、错误处理靠猜、调用方不知道会返回什么。点击按钮对比改造前后的代码。
export async function fetchOrders(params) {
const query = new URLSearchParams();
if (params.page) query.set('page', params.page);
if (params.status) query.set('status', params.status);
if (params.keyword) query.set('q', params.keyword);
const res = await fetch(`/api/orders?${query}`);
const json = await res.json();
// 谁能告诉我 json 长什么样?
if (json.code === 0) {
return json.data;
} else {
// msg 还是 message?
throw new Error(json.msg);
}
}
// 调用方
const data = await fetchOrders({ page: 1, status: 'paid' });
data.list.forEach((order) => {
// order.amount 还是 order.totalAmount?
// 编辑器没有任何提示,只能去后端代码里翻
render(order.id, order.amount);
});
// 问题清单:
// 1. 字段名写错只能运行时发现
// 2. 分页结构的字段无从得知
// 3. status 的合法取值没有约束
// 4. 错误分支返回的 msg 可能是 undefined
// 5. 重构时无法知道哪些地方依赖了哪些字段
改造带来的收益
这个改造的关键思路是:把“数据长什么样”从散落各处的口头约定,变成一个显式定义、集中维护、可被工具读取的东西。类型定义写一次,编辑器补全、编译检查、重构支持、文档生成全都跟着来。
十五、速查表与落地清单
最后把全文浓缩成一份可以贴在工位上的速查表。
| 你遇到的问题 | 可以先看看 | 关键点 |
|---|---|---|
| 函数参数不知道传什么 | 函数类型标注 | 在函数边界写注解,函数体内部靠推断 |
| 一个值可能是好几种类型 | 联合类型 + 收窄 | typeof / in / 判别字段 |
| 同一段逻辑要适用于多种类型 | 泛型 | 泛型要建立输入与输出的关系 |
| 需要从一个类型派生另一个类型 | 工具类型 / 映射类型 | Pick、Omit、Partial、Record |
| 字符串必须符合某种格式 | 模板字面量类型 | `on${Capitalize<K>}` |
| 接口返回的数据不可信 | unknown + 运行时校验 | zod / 自定义类型谓词 |
| 状态机 / 异步流程建模 | 判别联合类型 | 每个状态一个唯一的 status 字段 |
| 第三方库没有类型 | @types / 自己写 .d.ts |
先写最小声明,后续补全 |
| 类型报错看不懂 | 拆解交叉类型 | 用 type Debug = ... 逐层展开 |
| 老项目想上 TS | 渐进迁移 | allowJs → checkJs → strict,分批推进 |
落地清单
- 新项目直接开
strict。从第一天严格,比后期收紧便宜十倍。 - 类型注解只写在边界上。函数签名、导出对象、类的公开成员。
- 能推断的不要手写。冗余的类型注解只会增加维护成本。
- 用
unknown代替any。外部数据进来先当成未知,校验之后再收窄。 - 用判别联合建模状态。比一堆布尔标志位清晰得多。
- 用
import type导入纯类型。避免运行时依赖和循环引用。 - 用
satisfies而不是as。既验证形状,又保留精确推断。 - 用
never做穷尽性检查。让新增分支时的遗漏在编译期暴露。 - 外部数据一定要运行时校验。类型系统管不到网络返回和本地存储。
- 类型体操适可而止。报错信息看不懂的类型,就是在增加团队的负担。
- 把
tsc --noEmit放进 CI。否则类型检查迟早会形同虚设。 - 衡量标准只有一个:改需求时,有多少错误能在保存前被发现?这个数字越大,类型系统越有价值。
// 1. 判别联合 —— 状态建模
type Result<T> =
| { status: 'ok'; data: T }
| { status: 'error'; message: string };
// 2. 从常量推导类型 —— 单一事实来源
const ROLES = ['admin', 'editor', 'viewer'] as const;
type Role = (typeof ROLES)[number];
// 3. 类型安全的属性访问
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// 4. 运行时校验 + 类型推断
function isUser(v: unknown): v is User {
return typeof v === 'object' && v !== null
&& 'id' in v && 'name' in v;
}
// 5. 穷尽性检查
function assertNever(x: never): never {
throw new Error(`未处理的分支:${JSON.stringify(x)}`);
}
// 6. 保留推断的校验
const config = {
apiBase: '/api/v2',
timeout: 8000,
} satisfies Record<string, string | number>;
TypeScript 学习曲线里最陡的一段,不是语法,而是判断力——知道哪里该写类型、哪里该让推断干活、哪里该用 unknown 挡住风险、哪里该收手不写类型体操。这种判断力只能从实践中来:先写出来,报错了再想为什么,慢慢就会形成直觉。
最后给一个朴素的建议:不要试图一次性学完所有特性。先把基础类型、联合类型、收窄、接口、泛型这五样练熟,它们能覆盖日常开发九成以上的场景。至于条件类型、映射类型、模板字面量类型,等到你真的遇到“需要从一个类型派生出另一个类型”的需求时再回头看,那时你会发现它们其实很自然。
类型系统的终极目标不是“类型写得漂亮”,而是让你在改动代码时心里有底。当编辑器能替你记住每一个字段、每一次调用、每一处依赖的时候,你才能真正把注意力放在业务逻辑本身,而不是花在“这个字段到底叫什么”上。