1. 从一次类型报错说起:为什么“类型”这件事值得单独拎出来讲
刚接触 TypeScript 的人,十有八九都有过这样的经历:明明代码逻辑没问题,运行起来也正常,但编辑器里就是飘着一片红,鼠标悬停上去,提示一句让人摸不着头脑的话——Type 'string' is not assignable to type 'number'。你盯着那行代码看了半天,心里想的是“我传的就是个数字啊”,结果发现传进去的其实是个字符串形式的数字。这种“看起来对、实际上错”的瞬间,恰恰是 TypeScript 数据类型体系在发挥作用。
TypeScript 的数据类型,说白了就是给 JavaScript 那套“随心所欲”的变量加上一层“身份标签”。JavaScript 里你可以把数字、字符串、对象、函数随便塞进同一个变量,运行时才可能炸;TypeScript 则要求你在写代码的时候就把每个值的“身份”说清楚,编译器提前帮你把关。这个“身份”就是类型。它解决的问题很直接:让错误在编译阶段暴露,而不是等到线上用户点了一下按钮才崩。
这篇文章适合谁看?如果你已经会写 JavaScript,但对 TypeScript 的类型系统还停留在“知道有 string、number、boolean”这个层面,那这篇就是为你准备的。我会从最基础的类型讲起,一路讲到联合类型、交叉类型、类型断言、类型守卫这些实战中绕不开的东西,中间穿插我自己踩过的坑和总结出来的判断逻辑。目标只有一个:让你看完之后,面对一个变量的类型标注,能清楚地知道“为什么这么写”,而不是靠猜。
2. 原始类型与字面量类型:别小看这几个“基础款”
2.1 原始类型不只是 string、number、boolean
TypeScript 的原始类型(primitive types)包括string、number、boolean、null、undefined、symbol、bigint。前三个大家最熟,后几个在实际项目里也各有各的用武之地。
null和undefined这两个类型经常让人困惑。在默认配置下(strictNullChecks关闭),它们可以赋值给任何类型,这其实是个隐患。我建议所有新项目都打开strictNullChecks,让null和undefined只能赋给明确允许它们的类型。这样当你写一个函数返回string时,编译器会强制你处理“可能返回 null”的情况,而不是让调用方在运行时才发现拿到的是空值。
symbol和bigint用得相对少,但在特定场景下很关键。symbol适合做对象的唯一键,避免属性名冲突;bigint适合处理超出Number.MAX_SAFE_INTEGER的大整数,比如某些需要精确计算的场景。这两个类型的存在说明 TypeScript 的类型体系是跟着 JavaScript 的语言特性走的,不是凭空造出来的。
2.2 字面量类型:把“值”本身当成类型
字面量类型(literal types)是很多人容易忽略但极其好用的一个特性。你可以直接把一个具体的值当作类型来用:
let direction: "up" | "down" | "left" | "right"; direction = "up"; // 合法 direction = "forward"; // 报错:不能将 "forward" 分配给该类型这里"up"、"down"这些不是字符串类型,而是“只能是这个字符串”的类型。配合联合类型使用,就能实现类似枚举的效果,但比枚举更轻量、更灵活。
我个人的经验是:当一个变量的取值范围是有限且已知的时候,优先考虑字面量类型加联合类型,而不是用string然后靠文档或注释去约束。比如状态机的状态、配置项的选项、API 返回的固定字段值,都适合这么处理。这样做的好处是,当你写错一个值时,编辑器会立刻提示,而不是等到运行时才发现状态不对。
2.3 类型推断:什么时候该写,什么时候不该写
TypeScript 有很强的类型推断能力。你写let count = 0,它自动推断count是number;你写const name = "Alice",它推断name是"Alice"这个字面量类型(因为const不可变)。很多人刚学的时候喜欢给每个变量都手动标注类型,结果代码里全是冗余的: number、: string。
我的判断标准很简单:如果初始化表达式已经能明确表达类型,就不写;如果类型需要从上下文推断或者初始化值不足以表达意图,就写。比如函数参数通常需要写,因为编译器无法从调用处反推;函数返回值可以写也可以不写,但公共 API 的返回值建议写,方便阅读和后续维护。变量声明如果初始化值很明确,比如const list = [1, 2, 3],推断出来是number[],那就没必要再写一遍。
注意:
let和const的推断结果不同。let x = "hello"推断为string,而const x = "hello"推断为"hello"。这个差异在需要精确字面量类型的时候很关键。
3. 对象、数组与函数:复合类型的标注逻辑
3.1 对象类型:interface 和 type 的选择
描述一个对象的形状,TypeScript 提供了两种主要方式:interface和type。两者在很多场景下可以互换,但有一些细微差别值得注意。
interface可以被继承和合并声明,适合描述“一个可以被扩展的结构”,比如组件的 Props、API 的响应体。type更灵活,可以定义联合类型、交叉类型、条件类型等复杂类型,适合做类型运算和组合。
我自己的习惯是:描述对象结构优先用interface,需要做类型运算或者定义非对象类型时用type。这不是硬性规定,但能让代码风格更统一。比如:
interface User { id: number; name: string; email?: string; // 可选属性 readonly createdAt: Date; // 只读属性 }可选属性用?标注,只读属性用readonly标注。这两个修饰符在实际项目中非常实用:可选属性让你明确哪些字段可能不存在,只读属性防止意外修改。我见过不少 bug 是因为某个配置对象被意外改动了,加上readonly之后编译器会直接拦住这类操作。
3.2 数组与元组:什么时候用哪个
数组类型有两种写法:number[]和Array<number>,两者等价。我一般用number[],更简洁。数组表示“一组同类型的值”,长度不固定。
元组(tuple)则表示“长度固定、每个位置类型可以不同”的序列:
let point: [number, number] = [10, 20]; let entry: [string, number] = ["age", 30];元组在函数返回多个值时特别有用。比如一个函数需要返回“是否成功”和“数据或错误信息”,可以用[boolean, T | Error]这样的元组类型。不过元组的可读性不如对象,如果返回值超过三个,我建议还是用对象加字段名,别硬用元组。
3.3 函数类型:参数和返回值的标注
函数类型的标注包括参数类型和返回值类型:
function add(a: number, b: number): number { return a + b; } const multiply: (a: number, b: number) => number = (a, b) => a * b;参数的可选性用?表示,默认参数会自动变成可选。剩余参数用...加数组类型表示。返回值如果函数体足够简单,可以省略让编译器推断;但如果函数有多个返回分支,建议显式标注,避免推断出意料之外的类型。
有一个坑我踩过:当函数有多个返回语句且返回类型不同时,TypeScript 会推断出联合类型。比如一个函数有时返回string,有时返回number,推断结果就是string | number。如果调用方没有处理这个联合类型,就会报错。这时候要么显式标注返回类型并确保所有分支都符合,要么用类型守卫在调用方做收窄。
4. 联合、交叉与类型收窄:让类型“活”起来
4.1 联合类型:或的关系
联合类型用|表示“可以是这几种类型中的任意一种”:
function format(value: string | number): string { if (typeof value === "string") { return value.toUpperCase(); } return value.toFixed(2); }联合类型的核心价值在于“把可能性显式列出来”。当你拿到一个string | number的值时,编译器会强制你在使用前先判断它到底是哪种类型,否则只能调用两种类型共有的方法。这个约束看起来麻烦,实际上避免了很多运行时错误。
4.2 交叉类型:且的关系
交叉类型用&表示“同时满足多种类型”:
type Named = { name: string }; type Aged = { age: number }; type Person = Named & Aged; const p: Person = { name: "Alice", age: 30 };交叉类型常用于混入(mixin)模式,把多个类型的属性合并到一起。需要注意的是,如果两个类型有同名但类型不同的属性,交叉结果会是never,因为没有任何值能同时满足两个冲突的类型。
4.3 类型收窄:从宽到窄的判断过程
类型收窄(narrowing)是 TypeScript 类型系统里最实用的机制之一。当你对一个联合类型的值做判断时,TypeScript 会根据判断条件自动收窄类型范围:
function process(input: string | string[] | null) { if (input === null) { return; } if (Array.isArray(input)) { input.forEach(item => console.log(item)); // 这里 input 是 string[] } else { console.log(input.toUpperCase()); // 这里 input 是 string } }常用的收窄手段包括typeof、instanceof、in、Array.isArray、真值判断、相等判断等。TypeScript 的控制流分析会跟踪这些判断,在对应的分支里自动调整类型。
我个人的经验是:尽量用收窄而不是类型断言。类型断言(as)是“我比你编译器更懂”,收窄是“我用代码证明给你看”。前者绕过了检查,后者保留了安全性。只有在确实无法通过收窄表达意图时,才考虑断言,并且最好加上注释说明原因。
4.4 可辨识联合:处理多种形态的利器
可辨识联合(discriminated union)是联合类型的一个高级用法,适合描述“有多种形态、每种形态有不同字段”的数据:
type Result = | { status: "success"; data: string } | { status: "error"; message: string } | { status: "loading" }; function handle(result: Result) { switch (result.status) { case "success": console.log(result.data); // 收窄为 success 形态 break; case "error": console.log(result.message); // 收窄为 error 形态 break; case "loading": console.log("加载中"); break; } }这里status就是“可辨识属性”,它的字面量类型让 TypeScript 能在switch中精确收窄。这种模式在处理 API 响应、状态机、表单校验结果时非常常见,我几乎每个项目都会用到。
5. 泛型、断言与工具类型:进阶但绕不开
5.1 泛型:类型的参数化
泛型让类型可以像函数参数一样被传入和复用:
function identity<T>(value: T): T { return value; } const num = identity(42); // T 推断为 number const str = identity("hello"); // T 推断为 string泛型的价值在于“一次定义,多处复用,且保持类型信息不丢失”。比如一个wrapInArray<T>(value: T): T[]函数,传入number返回number[],传入string返回string[],不需要为每种类型写一遍。
泛型约束用extends表示:
function getLength<T extends { length: number }>(value: T): number { return value.length; }这样T必须满足“有 length 属性”的条件,函数体内才能安全访问value.length。
5.2 类型断言:什么时候用,什么时候别用
类型断言有两种写法:value as Type和<Type>value。后者在.tsx文件中会和 JSX 语法冲突,所以统一用as更稳妥。
断言的本质是“告诉编译器把这个值当成某个类型处理”,它不做任何运行时检查。所以断言用错了,编译能过,运行时照样崩。我的原则是:能用收窄就不用断言,能用泛型约束就不用断言,实在没办法才用断言,并且加注释说明为什么这里断言是安全的。
有一种情况断言是合理的:当你比编译器掌握更多信息时。比如从document.getElementById拿到HTMLElement | null,你明确知道某个 id 对应的元素是HTMLInputElement,这时候as HTMLInputElement是合理的,但前提是你确实能保证。
5.3 常用工具类型:Partial、Pick、Omit、Record
TypeScript 内置了一批工具类型,用来做常见的类型变换:
| 工具类型 | 作用 | 典型场景 |
|---|---|---|
Partial<T> | 所有属性变可选 | 更新操作,只传部分字段 |
Required<T> | 所有属性变必填 | 确保配置完整 |
Pick<T, K> | 只保留指定属性 | 从大类型中提取子集 |
Omit<T, K> | 排除指定属性 | 去掉敏感字段 |
Record<K, V> | 构造键值对类型 | 映射表、字典 |
Readonly<T> | 所有属性变只读 | 防止意外修改 |
这些工具类型在实战中极其高频。比如一个updateUser函数,参数类型用Partial<User>就比重新定义一个“所有字段可选”的接口更省事,而且和User保持同步。
5.4 自定义工具类型:条件类型与映射类型
当内置工具类型不够用时,可以自己写。条件类型用extends做判断:
type IsString<T> = T extends string ? true : false;映射类型用来遍历一个类型的所有属性并做变换:
type Nullable<T> = { [K in keyof T]: T[K] | null; };这两个特性组合起来能实现非常灵活的类型运算。不过我的建议是:除非确实需要,否则不要过度设计类型。类型代码也是代码,也需要维护。如果一个类型写得太复杂,三个月后自己都看不懂,那还不如用简单类型加运行时校验。
6. 实战中的类型设计心得与常见坑
6.1 类型设计的第一原则:从使用方出发
我见过很多项目,类型定义写得很“完整”,但用起来特别别扭。原因往往是类型定义是从数据源出发的,而不是从使用方出发的。比如一个 API 返回的原始数据结构嵌套很深,如果直接把原始结构定义成类型,调用方每次取值都要层层深入,很容易出错。
更好的做法是:在数据进入系统边界时做一次转换,把原始结构转成使用方友好的类型。比如 API 返回{ data: { user: { name: string } } },在请求层就把它转成{ name: string },后续代码都用这个扁平类型。这样类型定义和使用场景是对齐的,而不是和传输格式对齐的。
6.2 避免 any,但也不必追求零 any
any是 TypeScript 的“逃生舱”,它关闭了类型检查。滥用any会让 TypeScript 退化成 JavaScript,失去类型系统的价值。但完全不用any也不现实,尤其是在接入没有类型定义的第三方库时。
我的做法是:能用unknown就不用any。unknown是“类型安全的 any”,你必须先收窄才能使用。比如一个函数返回unknown,调用方必须判断类型后才能操作,而any则可以直接调用任何方法,风险大得多。
如果确实需要临时用any,我会加一个// TODO: 补充类型的注释,提醒自己后续处理。同时开启noImplicitAny,让编译器帮我发现隐式的any。
6.3 类型报错的排查思路
遇到类型报错时,我的排查顺序是:
- 看错误信息的第一行,它通常直接说明了问题:哪个类型不能赋给哪个类型。
- 找到报错位置对应的变量或表达式,确认它的实际类型是什么。把鼠标悬停在变量上,编辑器会显示推断出的类型。
- 检查类型定义是否和实际值匹配。常见情况是:接口定义说某个字段是
string,但实际数据里可能是null或undefined。 - 检查是否有类型收窄的机会。如果报错是因为联合类型没有收窄,加一个判断即可。
- 最后才考虑类型断言,并且要确认断言是安全的。
这个顺序能解决大部分类型问题。我见过有人一遇到报错就加as any,结果问题被掩盖,运行时才暴露,排查成本更高。
6.4 类型定义该放在哪里
小项目里类型定义可以就近放在使用它的文件里。但随着项目变大,类型定义散落各处会导致重复和冲突。我的经验是:
- 组件 Props 类型放在组件文件旁边,或者单独的
types.ts。 - API 相关类型放在统一的
api/types.ts或按模块拆分。 - 全局通用类型放在
src/types/目录下,按领域分文件。 - 第三方库缺失的类型放在
src/types/vendor.d.ts之类的声明文件里。
关键是保持一致性:同一个类型只定义一次,其他地方通过import引用。重复定义是类型冲突的主要来源。
6.5 一个容易被忽略的细节:类型和运行时的关系
TypeScript 的类型只存在于编译阶段,编译后会被完全擦除。这意味着你不能在运行时判断一个值的 TypeScript 类型,也不能根据类型做分支。运行时能用的只有 JavaScript 的值和typeof、instanceof这些操作符。
这个事实带来两个实践上的注意点:第一,类型断言不会做任何运行时检查,断言错了运行时照样出问题;第二,如果需要根据类型做运行时逻辑,必须自己写类型守卫函数,而不是依赖 TypeScript 的类型信息。
function isString(value: unknown): value is string { return typeof value === "string"; }这种“类型谓词”函数在运行时做检查,同时告诉编译器收窄结果,是连接类型世界和运行时世界的桥梁。
7. 我个人的几条类型使用习惯
写了几年 TypeScript 之后,有些习惯已经变成肌肉记忆了。分享几条,不一定适合所有人,但至少是我踩过坑之后觉得值得坚持的。
第一,新项目一定开 strict 模式。strict: true会打开strictNullChecks、noImplicitAny、strictFunctionTypes等一系列检查。刚开始会觉得麻烦,但习惯了之后,它帮你挡掉的 bug 远超你为它付出的成本。
第二,函数参数和公共 API 的返回值一定显式标注类型。函数体内部的局部变量可以靠推断,但对外暴露的接口必须明确。这样调用方不需要读你的实现就能知道类型,也方便后续重构。
第三,优先用 interface 描述对象,用 type 做类型运算。这不是硬规则,但能让代码风格统一,减少“这里该用哪个”的犹豫。
第四,遇到复杂类型先画出来再写。类型嵌套超过两层的时候,我会先在纸上或注释里把结构写清楚,再翻译成 TypeScript。直接写容易漏字段或者搞错嵌套层级。
第五,定期检查类型覆盖率。如果项目里any太多,类型系统的价值就大打折扣。可以定期搜索any和as的出现次数,看看有没有可以改进的地方。
最后说一个我最近才想明白的点:类型系统的终极目标不是“让编译器满意”,而是“让代码更容易被理解和修改”。如果一个类型定义让代码更难懂了,那它可能设计得不对。类型是给人看的,编译器只是顺便检查一下。