你写的 TypeScript,其实只是穿了件类型外套的 JavaScript

3 阅读8分钟

很多人学了半年 TS,代码里清一色 any,偶尔来个 string | number,然后在简历上写"熟练使用 TypeScript"。这篇文章,就是为了让你彻底告别这种状态。

先说清楚一件事:TypeScript 的类型系统到底在解决什么问题

JavaScript 是动态类型语言。变量可以今天是字符串,明天是数字,后天变成 undefined。这在小项目里无所谓,但一旦代码规模上来——比如一个有 50 个接口、20 个开发者协作的中大型前端项目——没有类型约束,就像在没有红绿灯的十字路口开车,每一次函数调用都是一次赌博

TypeScript 的本质是在编译阶段拦截这些赌局。它不改变运行时行为,但能在你写代码的那一刻,就告诉你哪里会出问题。

理解了这一点,再来看各种类型,你就不会觉得它们是语法糖,而是协议——你和编译器之间的协议,你和团队成员之间的协议。

一、基础类型:别觉得简单,细节决定成败

string / number / boolean

这三个是 TS 里用得最多的类型,也是最容易被忽视的。

let name: string = "Andy";
let age: number = 18;
let isLogin: boolean = false;

看起来平平无奇,但"类型安全"的价值在这里:

let name: string = "andy";
name.toUpperCase(); // ✅ 合法,string 有这个方法
name.toFixed();     // ❌ 直接报错,string 没有 toFixed

换成纯 JS,这个错误只会在运行时才被发现——可能是用户触发了某个边界条件,可能是在生产环境,可能是在凌晨三点。TS 把运行时错误前移到了编写时,这是它最核心的价值。

null 和 undefined 的处理是门学问

很多项目踩过这样的坑:后端接口返回的某个字段"理论上有值",但有时候会是 null。如果你的类型定义是 string,编译器不会报错,但运行时一旦拿到 null 去调用字符串方法,直接崩。

正确姿势:

interface User {
  nickname: string | null; // 明确告诉所有人:这个字段可能为空
}

这样当你试图直接调用 user.nickname.toUpperCase() 时,编译器会强制你先处理 null 的情况。这不是麻烦,这是把锅甩给编译器而不是留给用户

bigint:什么时候才需要它

JavaScript 的 number 类型基于 IEEE 754 双精度浮点数,能精确表示的最大整数是 2^53 - 1,也就是 9007199254740991。超过这个数,精度会丢失。

let big: bigint = 9007199254740991n; // 注意末尾的 n

金融系统、密码学、需要处理超大 ID 的场景才会用到它。普通业务开发遇到的机会不多,但知道它存在,不会在某天看到 n 结尾的数字一脸懵。

二、字面量类型与联合类型:从"能用"到"好用"的关键一跳

字面量类型

type Direction = "left" | "right" | "up" | "down";

function move(dir: Direction) {
  // ...
}

如果参数类型是 string,你传 "diagonal" 进去编译器不会说话。但用字面量联合类型,"diagonal" 直接飘红。类型越窄,保护越强

这个思路很重要:在你确定某个值只会是有限几种可能的时候,不要偷懒用 string,用字面量联合类型把范围锁死。

联合类型与类型缩小(Type Narrowing)

联合类型(Union)表示"这个值可能是 A,也可能是 B":

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)

联合是"或",交叉是"且":

type User = { name: string; email: string };
type Admin = User & { role: "admin"; permissions: string[] };

Admin 必须同时满足 User 和后面那个对象的结构。在实际项目里,这是组合模块类型的利器,比继承更灵活,比重新定义更省力。

三、any、unknown、never:三个经常被误用的类型

any:能不用就不用

let x: any;
x.foo.bar.baz(); // 不报错,但运行时爆炸

any 是类型系统的逃生舱。它告诉编译器"别管我,我自己负责"。偶尔处理真的无法预知结构的数据,或者接入没有类型声明的第三方库,可以用。但如果你的代码里 any 满天飞,TypeScript 就成了摆设——你得到了所有 TS 的编译复杂度,却没有得到任何类型安全。

unknown:any 的负责任替代品

unknown 同样表示"不知道是什么类型",但它要求你在使用前必须先做类型检查

let x: unknown;

if (typeof x === "string") {
  x.toUpperCase(); // ✅ 通过检查后才能用
}

x.toUpperCase(); // ❌ 直接报错

处理外部输入、API 响应、用户数据时,unknown 是比 any 更安全的选择。

never:表示"这里不应该被执行到"

never 有两个核心用途:

1. 表示函数不会正常返回

function throwError(msg: string): never {
  throw new Error(msg);
}

2. 穷举检查(Exhaustive Check)——这个才是精髓

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 类型系统里最有表达力的特性。你可以把它理解成类型层面的参数——函数接受值的参数,泛型接受类型的参数。

function identity<T>(value: T): T {
  return value;
}

identity<string>("hello"); // 返回类型是 string
identity<number>(42);      // 返回类型是 number

进一步,泛型可以加约束:

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:把所有字段变成可选

interface User {
  id: string;
  name: string;
  email: string;
}

type UpdateUserPayload = Partial<User>;
// 等价于 { id?: string; name?: string; email?: string }

更新接口往往只需要传部分字段,Partial 比重新定义一个新 interface 优雅得多。

Pick 和 Omit:精确裁剪类型

type UserPreview = Pick<User, "id" | "name">;
// 只保留 id 和 name

type PublicUser = Omit<User, "password" | "internalNotes">;
// 去掉敏感字段

这两个是一对互补工具。前端展示层经常需要的"脱敏版接口类型",用 Omit 一行搞定。

Record:快速定义映射结构

const userCache: Record<string, User> = {};
// 等价于 { [key: string]: User }

Record<K, V> 比写索引签名更直观。后台管理系统里的权限映射、字典数据、配置对象,Record 用起来非常顺手。

Exclude 和 Extract:在联合类型里做集合运算

type Status = "active" | "inactive" | "banned";

type ActiveStatus = Extract<Status, "active" | "inactive">;
// 结果:"active" | "inactive"

type NonBanned = Exclude<Status, "banned">;
// 结果:"active" | "inactive"

Extract 是取交集,Exclude 是取差集。在处理复杂状态枚举时会用到。

六、条件类型与映射类型:进阶但值得了解

条件类型

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

语法和三元运算符一样,但它运作在类型层面。很多 TS 内置的工具类型(比如 Exclude)底层就是用条件类型实现的。

映射类型

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

keyof T 拿到 T 的所有键,in 遍历它们,然后给每个键加上 readonly 修饰符。PartialRequiredReadonly 这些内置工具类型,背后全是映射类型。

七、interface vs type:别被这个问题困扰太久

这是 TS 社区里被讨论烂了的问题,结论其实挺简单:

interface 定义对象结构,因为它支持声明合并(Declaration Merging) ——同名 interface 会自动合并,这在扩展第三方库类型时很有用。

type 定义联合类型、交叉类型、别名,因为 interface 做不到 type Status = "active" | "inactive" 这种写法。

// 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 最好的使用方式,是把它当成一个不会累、不会忘、全年无休的代码审查员