为什么你需要搞懂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 方式 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 内置了 ReadonlyPartialRequiredPickOmit 等工具类型,没必要自己写。但理解原理很重要。

常见错误#2:使用 Omit 时,如果省略的键在类型中不存在,TS 不会报错,但你可能没达到预期。

interface Task { id: number; title: string; completed: boolean; } // 想省略 completed,但写错了 'complete' type TaskWithoutCompleted = Omit<Task, 'complete'>; // 结果:仍然有 completed,因为 'complete' 不在键中,Omit 直接忽略了不存在的键

解决方法:先声明一个包含所有要删除键的联合类型,用 Exclude 确保只删除存在的键。或者用第三方库 ts-toolbeltOmit 版本。更简单的是加个类型测试:

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 暂时跳过(但别长期依赖)。

最佳实践总结

const palette = { red: [255, 0, 0], green: '#00ff00', } satisfies Record<string, string | number[]>; // palette.red 的类型仍然是 number[],而不是 string | number[] palette.red.map(x => x); // 能正常使用数组方法

最后记住:类型系统是你的朋友,不是敌人。刚开始写 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的类型系统是它最大的优势,但也是学习曲线最陡的部分。建议先掌握基础类型和接口,再学泛型和工具类型,最后再挑战条件类型和模板字面量类型。不要一上来就追求所有高级特性。