很多人学了半年 TS,代码里清一色
any,偶尔来个string | number,然后在简历上写"熟练使用 TypeScript"。这篇文章,就是为了让你彻底告别这种状态。
先说清楚一件事:TypeScript 的类型系统到底在解决什么问题
JavaScript 是动态类型语言。变量可以今天是字符串,明天是数字,后天变成 undefined。这在小项目里无所谓,但一旦代码规模上来------比如一个有 50 个接口、20 个开发者协作的中大型前端项目------没有类型约束,就像在没有红绿灯的十字路口开车,每一次函数调用都是一次赌博。
TypeScript 的本质是在编译阶段拦截这些赌局。它不改变运行时行为,但能在你写代码的那一刻,就告诉你哪里会出问题。
理解了这一点,再来看各种类型,你就不会觉得它们是语法糖,而是协议------你和编译器之间的协议,你和团队成员之间的协议。
一、基础类型:别觉得简单,细节决定成败
string / number / boolean
这三个是 TS 里用得最多的类型,也是最容易被忽视的。
ts
let name: string = "Andy";
let age: number = 18;
let isLogin: boolean = false;
看起来平平无奇,但"类型安全"的价值在这里:
ts
let name: string = "andy";
name.toUpperCase(); // ✅ 合法,string 有这个方法
name.toFixed(); // ❌ 直接报错,string 没有 toFixed
换成纯 JS,这个错误只会在运行时才被发现------可能是用户触发了某个边界条件,可能是在生产环境,可能是在凌晨三点。TS 把运行时错误前移到了编写时,这是它最核心的价值。
null 和 undefined 的处理是门学问
很多项目踩过这样的坑:后端接口返回的某个字段"理论上有值",但有时候会是 null。如果你的类型定义是 string,编译器不会报错,但运行时一旦拿到 null 去调用字符串方法,直接崩。
正确姿势:
ts
interface User {
nickname: string | null; // 明确告诉所有人:这个字段可能为空
}
这样当你试图直接调用 user.nickname.toUpperCase() 时,编译器会强制你先处理 null 的情况。这不是麻烦,这是把锅甩给编译器而不是留给用户。
bigint:什么时候才需要它
JavaScript 的 number 类型基于 IEEE 754 双精度浮点数,能精确表示的最大整数是 2^53 - 1,也就是 9007199254740991。超过这个数,精度会丢失。
ts
let big: bigint = 9007199254740991n; // 注意末尾的 n
金融系统、密码学、需要处理超大 ID 的场景才会用到它。普通业务开发遇到的机会不多,但知道它存在,不会在某天看到 n 结尾的数字一脸懵。
二、字面量类型与联合类型:从"能用"到"好用"的关键一跳
字面量类型
ts
type Direction = "left" | "right" | "up" | "down";
function move(dir: Direction) {
// ...
}
如果参数类型是 string,你传 "diagonal" 进去编译器不会说话。但用字面量联合类型,"diagonal" 直接飘红。类型越窄,保护越强。
这个思路很重要:在你确定某个值只会是有限几种可能的时候,不要偷懒用 string,用字面量联合类型把范围锁死。
联合类型与类型缩小(Type Narrowing)
联合类型(Union)表示"这个值可能是 A,也可能是 B":
ts
function print(val: string | number) {
if (typeof val === "string") {
console.log(val.toUpperCase()); // 这里 TS 已经知道 val 是 string
} else {
console.log(val.toFixed(2)); // 这里 TS 已经知道 val 是 number
}
}
这叫类型缩小(Type Narrowing)------通过条件判断,编译器会在不同分支里自动推断出更精确的类型。理解这个机制,是写出干净 TS 代码的前提。
交叉类型(Intersection)
联合是"或",交叉是"且":
ts
type User = { name: string; email: string };
type Admin = User & { role: "admin"; permissions: string[] };
Admin 必须同时满足 User 和后面那个对象的结构。在实际项目里,这是组合模块类型的利器,比继承更灵活,比重新定义更省力。
三、any、unknown、never:三个经常被误用的类型
any:能不用就不用
ts
let x: any;
x.foo.bar.baz(); // 不报错,但运行时爆炸
any 是类型系统的逃生舱。它告诉编译器"别管我,我自己负责"。偶尔处理真的无法预知结构的数据,或者接入没有类型声明的第三方库,可以用。但如果你的代码里 any 满天飞,TypeScript 就成了摆设------你得到了所有 TS 的编译复杂度,却没有得到任何类型安全。
unknown:any 的负责任替代品
unknown 同样表示"不知道是什么类型",但它要求你在使用前必须先做类型检查:
ts
let x: unknown;
if (typeof x === "string") {
x.toUpperCase(); // ✅ 通过检查后才能用
}
x.toUpperCase(); // ❌ 直接报错
处理外部输入、API 响应、用户数据时,unknown 是比 any 更安全的选择。
never:表示"这里不应该被执行到"
never 有两个核心用途:
1. 表示函数不会正常返回
ts
function throwError(msg: string): never {
throw new Error(msg);
}
2. 穷举检查(Exhaustive Check)------这个才是精髓
ts
type Direction = "up" | "down" | "left";
function move(dir: Direction) {
if (dir === "up") { /* ... */ }
else if (dir === "down") { /* ... */ }
else if (dir === "left") { /* ... */ }
else {
const _check: never = dir;
// 如果未来 Direction 加了 "right" 但这里没处理
// 编译器会在这行报错,提醒你补全逻辑
}
}
这是一个防御性编程技巧:让编译器帮你检查所有情况是否都被覆盖。在处理状态机、分支逻辑密集的业务代码里,能避免非常隐蔽的 bug。
四、泛型:类型系统的"函数"
泛型(Generic)是 TypeScript 类型系统里最有表达力的特性。你可以把它理解成类型层面的参数------函数接受值的参数,泛型接受类型的参数。
ts
function identity<T>(value: T): T {
return value;
}
identity<string>("hello"); // 返回类型是 string
identity<number>(42); // 返回类型是 number
进一步,泛型可以加约束:
ts
function getLength<T extends { length: number }>(val: T): number {
return val.length;
}
getLength("hello"); // ✅ string 有 length
getLength([1, 2, 3]); // ✅ array 有 length
getLength(123); // ❌ number 没有 length,报错
泛型约束用 extends 关键字,表示"T 必须满足某个结构"。这让你写出的工具函数既灵活又安全。
五、工具类型:不要重复造轮子
TS 内置了一批工具类型(Utility Types) ,专门用于对已有类型进行变形。掌握这些,能让你的类型定义简洁一个量级。
Partial:把所有字段变成可选
ts
interface User {
id: string;
name: string;
email: string;
}
type UpdateUserPayload = Partial<User>;
// 等价于 { id?: string; name?: string; email?: string }
更新接口往往只需要传部分字段,Partial 比重新定义一个新 interface 优雅得多。
Pick 和 Omit:精确裁剪类型
ts
type UserPreview = Pick<User, "id" | "name">;
// 只保留 id 和 name
type PublicUser = Omit<User, "password" | "internalNotes">;
// 去掉敏感字段
这两个是一对互补工具。前端展示层经常需要的"脱敏版接口类型",用 Omit 一行搞定。
Record:快速定义映射结构
ts
const userCache: Record<string, User> = {};
// 等价于 { [key: string]: User }
Record<K, V> 比写索引签名更直观。后台管理系统里的权限映射、字典数据、配置对象,Record 用起来非常顺手。
Exclude 和 Extract:在联合类型里做集合运算
ts
type Status = "active" | "inactive" | "banned";
type ActiveStatus = Extract<Status, "active" | "inactive">;
// 结果:"active" | "inactive"
type NonBanned = Exclude<Status, "banned">;
// 结果:"active" | "inactive"
Extract 是取交集,Exclude 是取差集。在处理复杂状态枚举时会用到。
六、条件类型与映射类型:进阶但值得了解
条件类型
ts
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
语法和三元运算符一样,但它运作在类型层面。很多 TS 内置的工具类型(比如 Exclude)底层就是用条件类型实现的。
映射类型
ts
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
keyof T 拿到 T 的所有键,in 遍历它们,然后给每个键加上 readonly 修饰符。Partial、Required、Readonly 这些内置工具类型,背后全是映射类型。
七、interface vs type:别被这个问题困扰太久
这是 TS 社区里被讨论烂了的问题,结论其实挺简单:
用 interface 定义对象结构 ,因为它支持声明合并(Declaration Merging) ------同名 interface 会自动合并,这在扩展第三方库类型时很有用。
用 type 定义联合类型、交叉类型、别名 ,因为 interface 做不到 type Status = "active" | "inactive" 这种写法。
ts
// interface:适合对象,支持 extends 和合并
interface User {
name: string;
}
interface User {
age: number; // 合并生效,不报错
}
// type:更灵活,适合联合/交叉/别名
type ID = string | number;
type AdminUser = User & { role: "admin" };
实际项目里,两者往往混用。不必教条,根据场景选最合适的。
八、总结表
以下 12 个类型/特性,覆盖了日常前端开发 90% 以上的场景:
| 类型 / 特性 | 核心价值 |
|---|---|
string / number / boolean |
基础约束,防止类型误用 |
| 联合类型 | 处理多态数据,配合类型缩小使用 |
interface |
定义数据结构,团队协作的契约 |
type |
定义联合、交叉、别名,比 interface 更灵活 |
泛型 <T> |
复用逻辑的同时保持类型安全 |
Record |
快速定义映射/字典结构 |
Partial |
更新接口的标配 |
Pick / Omit |
从已有类型裁剪出你需要的形状 |
never |
穷举检查,让编译器替你兜底 |
结语
TypeScript 的类型系统不是负担,是把 bug 消灭在编辑器里的机会。每一个精确的类型定义,都是在为未来的自己、为团队省下一次排查 bug 的时间。
从今天起,遇到 any,先想想能不能换成 unknown。遇到 string,先想想能不能换成字面量联合类型。把类型写得越具体,编译器能帮你做的就越多。
TypeScript 最好的使用方式,是把它当成一个不会累、不会忘、全年无休的代码审查员。