为什么你需要搞懂TypeScript的类型系统
别的不说,你肯定见过这种代码:
function getData(id: number) {
return fetch(`/api/data/${id}`).then(res => res.json());
}
// 调用的时候完全不知道返回的是什么
const data = getData(1); // data: any
然后后面用的时候一旦访问了不存在的属性,运行时直接报 undefined,排查半天发现是接口返回的字段名写错了。这种坑我踩了不下二十次。TypeScript 的类型系统就是用来根治这个问题的——它让你在写代码的时候就知道数据的形状,编译阶段就干掉一大半低级错误。
换个角度看,类型系统就是给 JavaScript 这匹野马套上缰绳。你不是嫌 JS 太灵活容易出 bug 吗?TS 让你在灵活和安全之间找到平衡。
基础类型:别再用 `any` 偷懒了
大多数人入门 TS 的时候,遇到不确定的类型就甩个 any,这跟没用 TS 有什么区别?先掌握这些基础类型:
// 原始类型
let name: string = '张三';
let age: number = 28;
let isActive: boolean = true;
let result: undefined = undefined;
let data: null = null;
// 数组:两种写法
let list1: number[] = [1, 2, 3];
let list2: Array<string> = ['a', 'b'];
// 元组:固定长度和类型
let tuple: [string, number] = ['hello', 42];
// 枚举:常量集合
enum Color {
Red,
Green,
Blue
}
let myColor: Color = Color.Red; // 0
实战经验提醒:枚举默认从0开始,如果你需要自定义值,可以写 Red = 1,但小心数字枚举会有反向映射(Color[0] 也能拿到 'Red'),这有时候会让人困惑。
对象的类型:interface vs type
很多人纠结用 interface 还是 type,看我的经验:
- 定义对象形状、类实现、声明合并 -> 用
interface
- 需要联合类型、交叉类型、工具类型 -> 用
type
// interface 方式
interface User {
id: number;
name: string;
email?: string; // 可选属性
readonly createdAt: Date; // 只读
}
// type 方式
type UserType = {
id: number;
name: string;
email?: string;
};
// 类型可以扩展(交叉类型)
type Admin = UserType & { role: 'admin' };
// interface 可以继承
interface AdminInterface extends User {
role: 'admin';
}
注意:如果你写第三方库给别人用,优先用 interface,因为可以声明合并(比如往 window 上加属性)。如果是内部业务代码,我更喜欢 type,更灵活。
联合类型与类型守卫:让 TS 帮你做逻辑判断
联合类型是 TS 的杀手锏之一。比如一个变量可能是字符串或者数字:
function formatId(id: string | number) {
// 这里如果直接 id.toUpperCase() 会报错,因为 number 没有这个方法
if (typeof id === 'string') {
return id.toUpperCase(); // TS 知道这里是 string
} else {
return id.toFixed(2); // TS 知道这里是 number
}
}
这种通过 typeof 缩小类型范围的做法就是类型守卫。还有更高级的:
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
default:
// 利用 never 类型做穷尽检查
const _exhaustive: never = shape;
return _exhaustive;
}
}
常见错误#1:忘记处理所有分支,导致 never 赋值报错。解决办法就是补全缺失的 case。如果后续增加了新的形状(比如 Triangle),TS 会立刻告诉你 getArea 需要更新。
泛型:写一次,用多种类型
泛型让函数、接口、类变成“类型参数化”。没它你只能为每种类型单独写一个函数:
// 不用泛型:每个类型都得写一遍
function firstNumber(arr: number[]): number { return arr[0]; }
function firstString(arr: string[]): string { return arr[0]; }
// 用泛型:一次搞定
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // num: number
const str = first(['a', 'b']); // str: string
泛型还能约束。比如你想让传入的类型必须有 length 属性:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): T {
console.log(arg.length);
return arg;
}
logLength('hello'); // 字符串有 length
logLength([1, 2, 3]); // 数组也有
logLength(123); // ❌ 报错:number 没有 length
注意:泛型约束用 extends,不是 implements。Kotlin 或者 Java 转过来的容易写错。
实际实战经验:泛型默认值
早期我写 React 组件时,常这样:
interface Props<T = any> {
data: T;
onChange: (val: T) => void;
}
// 如果不给默认值,所有用到的地方都得显式传泛型参数,很烦
给泛型一个默认值 any,但别经常这么干,会丢失类型安全。更好的做法是尽量推导出来。
高级类型:条件类型与映射类型
这部分是 TS 类型系统的核武器,用好了代码量减少 50%。
条件类型:像三元表达式一样
type IsString<T> = T extends string ? 'yes' : 'no';
type A = IsString<'hello'>; // 'yes'
type B = IsString<number>; // 'no'
实战例子:提取 Promise 里的类型
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Result = UnwrapPromise<Promise<string>>; // string
type Result2 = UnwrapPromise<number>; // number
这里的 infer 关键字用于在条件类型中声明一个待推断的类型变量。infer U 表示“如果能匹配到 Promise<某个类型>,就把那个类型赋值给 U”。
映射类型:批量修改对象属性
// 把所有属性变成只读
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// 把所有属性变成可选
type Partial<T> = {
[P in keyof T]?: T[P];
};
// 使用
type User = { id: number; name: string };
type ReadonlyUser = Readonly<User>;
// { readonly id: number; readonly name: string }
TS 内置了 Readonly、Partial、Required、Pick、Omit 等工具类型,没必要自己写。但理解原理很重要。
常见错误#2:使用 Omit 时,如果省略的键在类型中不存在,TS 不会报错,但你可能没达到预期。
interface Task {
id: number;
title: string;
completed: boolean;
}
// 想省略 completed,但写错了 'complete'
type TaskWithoutCompleted = Omit<Task, 'complete'>;
// 结果:仍然有 completed,因为 'complete' 不在键中,Omit 直接忽略了不存在的键
解决方法:先声明一个包含所有要删除键的联合类型,用 Exclude 确保只删除存在的键。或者用第三方库 ts-toolbelt 的 Omit 版本。更简单的是加个类型测试:
type AssertCompletedExists<T> = 'completed' extends keyof T ? true : never;
type Check = AssertCompletedExists<Task>; // 如果是 never 说明 completed 不存在
实际经验:如何设计类型系统才不会翻车
1. 永远不要写 `any` 除非万不得已
你写 any 的一瞬间,TS 对你的保护就失效了。真的需要绕过类型检查时,用 unknown 代替——它更安全,因为使用 unknown 类型的值必须先做类型断言或类型守卫:
function safelyParseJSON(json: string): unknown {
return JSON.parse(json);
}
const data = safelyParseJSON('{"name":"张三"}');
// 必须主动断言才能用
const userName = (data as { name: string }).name;
2. 多用 `as const` 让字面量类型更精确
// 不加 as const,类型是 string[]
const colors = ['red', 'green', 'blue'];
// 加 as const,类型是 readonly ["red", "green", "blue"]
const colorsStrict = ['red', 'green', 'blue'] as const;
// 可以用来做精确的联合类型
function setTheme(color: typeof colorsStrict[number]) { }
setTheme('red'); // OK
setTheme('yellow'); // ❌ 报错
3. 小心函数重载的坑
TS 支持函数重载,但实现签名必须兼容所有重载签名:
function pickCard(x: number): { suit: string; card: number };
function pickCard(x: string): { suit: string; rank: string };
// 实现签名不能直接写 function pickCard(x: any): any,要写更具体的
function pickCard(x: number | string): { suit: string; card?: number; rank?: string } {
if (typeof x === 'number') {
return { suit: 'hearts', card: x };
} else {
return { suit: 'clubs', rank: x };
}
}
实战经验:重载签名和实现签名的参数数量必须一致,否则会报“实现签名与重载签名不兼容”。
4. 类型声明文件的 `declare` 妙用
当你需要引入一个没有类型定义的 JS 库,可以在项目中写 .d.ts 文件:
// lib.d.ts
declare module 'old-js-lib' {
export function doSomething(config: Record<string, unknown>): void;
export const version: string;
}
这样 TS 就能识别 import 了。如果库很大,可以用 @types/xxx 包,没有就自己写,或者用 // @ts-ignore 暂时跳过(但别长期依赖)。
最佳实践总结
- 开启 strict 模式:
tsconfig.json 里 strict: true,它包含了 noImplicitAny、strictNullChecks 等关键开关。我见过太多项目关了 strictNullChecks 然后不断出现 undefined 不是函数 的运行时错误。
- 使用
satisfies 操作符(TS 4.9+):既保留类型的窄化,又保持推导的精确性。
const palette = {
red: [255, 0, 0],
green: '#00ff00',
} satisfies Record<string, string | number[]>;
// palette.red 的类型仍然是 number[],而不是 string | number[]
palette.red.map(x => x); // 能正常使用数组方法
- 类型不要嵌套太深:超过两层嵌套的条件类型会让编译器变慢,人也看不懂。实在复杂就拆成多个辅助类型。
- 写测试:用
expect-type 库来断言类型是否符合预期,比如 expectTypeOf().toMatchTypeOf<(x: number) => void>()。
- 团队约定:在项目中统一
interface 前缀 I?很多人争论。我建议不加前缀,用 IUser 反而多余,因为 TS 本身就是类型注释,一眼就能看出是类型。
最后记住:类型系统是你的朋友,不是敌人。刚开始写 TS 时你可能会被各种报错搞疯,但坚持下来,你会发现代码自文档化、重构时改一处类型处处报错、IDE 智能提示飞起——这些回报绝对值回票价。
// 一个完整的实战例子:用泛型实现类型安全的 API 请求
interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
async function fetchApi<T>(url: string): Promise<T> {
const response = await fetch(url);
const result: ApiResponse<T> = await response.json();
if (result.code !== 0) {
throw new Error(result.message);
}
return result.data;
}
// 使用:
interface User {
id: number;
name: string;
}
const user = await fetchApi<User>('/api/user/1');
// user 的类型就是 User,能安全访问 user.name
这个例子涵盖了泛型、接口、Promise 类型推导。如果你能完全理解它,说明你已经掌握了 TS 类型系统的核心。
学习路径:TypeScript的类型系统是它最大的优势,但也是学习曲线最陡的部分。建议先掌握基础类型和接口,再学泛型和工具类型,最后再挑战条件类型和模板字面量类型。不要一上来就追求所有高级特性。