TypeScript 基础类型——这个东西听起来好像就是“给变量加个类型”而已,但等你真正上手写一段时间,会发现它其实是整个 TypeScript 世界里最值得花时间啃透的部分。你可以不背住每个工具类型的实现,但基础类型如果理解得不扎实,后面看函数重载、泛型、类型体操这些内容的时候,基本会一脸懵。这篇内容不是官网手册的翻译,而是我结合自己在项目里实际踩过坑之后总结的学习笔记,适合刚接触 TypeScript 的前端开发者,也适合那些已经写了几个月 JS、准备把代码迁移到 TS 的朋友。
我先把话放在前面:TypeScript 的基础类型并不难,难的是你搞不懂“为什么要这样设计”。所以这篇文章我会在讲每个类型的同时,解释它背后的逻辑,再给你看实际代码。最后一部分是我整理的一些配置和报错排查经验,包括最近挺多人碰到的 tsconfig 里 baseUrl 弃用提示,我都会聊到。
1. 基础类型:TypeScript 的“原话”
1.1 为什么 TypeScript 非要搞一套基础类型
很多从 JavaScript 转过来的人一开始都会有个疑问:JS 本身已经有 number、string、boolean 这些类型了,为什么 TypeScript 还要再声明一遍?
这个问题其实问到了点子上。JavaScript 里的类型是运行时才确定的,同一个变量今天可能是字符串,明天可能就是数字。你写let a = '1';然后a = 2;,JS 不会拦你,只会让你在后面的逻辑里自己收拾烂摊子。TypeScript 做的事情,是把这层检查提前到代码编译阶段,让你在写代码的时候就明确:这个变量能装什么东西、不能装什么东西。
所以 TypeScript 的基础类型,本质上就是“给 JavaScript 的动态值加上静态标签”。加了这个标签之后,编辑器能给你更靠谱的补全提示,重构的时候能帮你找到所有引用点,更重要的是,大量低级错误能在保存代码的一瞬间暴露出来,而不是等线上用户操作到某条分支才炸掉。
我自己的体会是:能不能用好 TypeScript,关键是能不能理解“类型是一种约束”这个观念。它不是让你多写几行字,而是让你在描述数据的时候更严谨。
1.2 每个基础类型的真实行为
TypeScript 的基础类型和 JavaScript 原始类型一一对应,常见的就这几个:boolean、number、string、null、undefined、symbol、bigint。下面用一个表格把它们的典型用法列出来,直观点:
| 类型 | 写法示例 | 说明 |
|---|---|---|
| boolean | let isDone: boolean = false; | 只有 true / false |
| number | let count: number = 42; | 整数、浮点数、NaN、Infinity 都算 |
| string | let name: string = 'typescript'; | 单引号、双引号、模板字符串都行 |
| null | let n: null = null; | 只能赋值为 null |
| undefined | let u: undefined = undefined; | 只能赋值为 undefined |
| symbol | let s: symbol = Symbol('id'); | 表示唯一标识符 |
| bigint | let big: bigint = 123n; | 用于超大整数 |
这里有个特别容易忽略的点:null和undefined在默认情况下,可以赋值给任何类型。这句话是什么意思?也就是说你写let name: string = null;,在不开strictNullChecks的时候是合法的。很多老项目里就是这么写过来的,导致运行时经常出现Cannot read property of null。新项目我强烈建议你把strict模式打开,让null和undefined老老实实待在自己的类型里。
再说number类型,不要以为它只表示“整数”。JavaScript 里只有一种数字类型,也就是 IEEE 754 双精度浮点数,所以 TypeScript 的number覆盖了整数、小数、NaN、Infinity这些。你写let num: number = 1 / 0;也是合法的,因为结果是Infinity。
1.3 typeScript和JS的区别:不仅仅是“写个类型”
TypeScript 和 JavaScript 的最大区别,不是语法上多了冒号和类型注解,而是“类型系统”从无到有。具体的差异可以拆成下面这几点:
- 静态检查 vs 动态检查。JavaScript 在运行时才暴露类型问题,TypeScript 在编译阶段就做检查。
- TypeScript 是 JavaScript 的超集。任何合法 JS 代码理论上都可以当作 TS 代码运行,只是可能因为类型缺失而出现类型错误。
- 类型注解是“可选的”。你不写类型,TypeScript 会进行类型推断;写了类型,就多一层约束。
- 运行前会被编译成 JavaScript,类型信息会被删除,不会影响运行时性能。
很多初学者会纠结:那我是不是要把所有变量都写上类型?其实不用。TypeScript 有很聪明的类型推断机制,比如let count = 42;它会自动推断成number。你只需要在函数参数、函数返回值、对象结构这些“边界”位置显式标注类型,中间的部分让推断去干活。
这个设计的好处是:你从 JS 迁移到 TS 的时候,可以先保留大部分 JS 代码,只给关键入口加上类型,再逐步收紧,不用一夜之间全部改造完。
2. 数组、元组与枚举:数据结构里的类型细节
2.1 数组类型:从 number[] 到 ReadonlyArray
数组是每个前端项目里都躲不开的数据结构。在 TypeScript 里,声明数组类型有两种等价写法:number[]和Array<number>。我喜欢用number[],读起来更自然;但写泛型风格Array<number>在某些场景下更统一,比如Array<string | null>,两种写法都没毛病,选一种保持一致就好。
除了基础写法,还要知道readonly修饰符。一个只读数组readonly number[]表示这个数组本身不能增删改元素。你别小看这个约束,很多数据源自外部接口,你希望保证它在组件内部不被意外修改,那就用ReadonlyArray<T>或者readonly T[]来声明。
实际项目中,最容易出错的数组类型场景是“数组里混合了不同类型的值”。比如后端返回的数据可能是[1, '2', 3],这时候用(string | number)[]表示,而不是直接用any[]。用any[]确实省事,但等于把类型检查全扔了,后面代码里做什么操作都不会有提示,这很危险。
2.2 元组类型:数量和顺序也是类型的一部分
元组(Tuple)是 TypeScript 里一个很有意思的类型,它允许你声明一个固定长度、固定元素顺序和类型的数组。比如[string, number],意思就是第一个元素必须是字符串,第二个元素必须是数字,顺序不能反。
为什么需要元组?典型场景是处理 CSV、坐标数据、函数返回多个值时。比如一个函数返回[错误信息, 数据],你就可以声明成[string, T]这样的元组,调用方取值时就能明确知道第一个值是错误信息,第二个是数据。
元组也有几个进阶用法:可选元素,比如[string, number?];剩余元素,比如[string, ...number[]],表示第一个是字符串,后面可以接任意多个数字。这些在复杂数据处理里非常有用,但说实话,日常业务代码里用得不多,不过面试常问,还是得掌握。
一个常见的坑是:元组类型并不是“完全不可变”的。let tuple: [string, number] = ['a', 1]; tuple.push(2);在 TypeScript 严格模式下可能会报错,但在某些配置下,push方法仍然存在,因为数组方法不会因为元组的固定长度就消失。所以如果你要的是不可变元组,记得加上readonly,比如readonly [string, number]。
2.3 枚举:方便,但必须知道编译后的样子
枚举(enum)是 TypeScript 特有的,JavaScript 里没有原生对应的东西。它可以让一组常量有更可读的名字,比如:
enum Direction { Up, Down, Left, Right, }默认情况下,Up的值是 0,Down是 1,依次递增。你还可以指定字符串值:
enum Color { Red = 'RED', Green = 'GREEN', Blue = 'BLUE', }枚举很好用,但它有一个需要特别注意的问题:编译后的代码并不是简单地把枚举替换成值,而是会生成一个对象。数字枚举还会生成反向映射,也就是Direction[0]能取到'Up'。这会导致打包体积变大,并且有些工具库会比较排斥枚举。比如在 React 生态里的不少库,官方类型定义都推荐使用“联合类型 + 常量对象”来替代枚举。
如果项目里确实需要枚举,建议优先使用字符串枚举,因为它更明确,反向映射带来的隐患也少一些。还要注意const enum的使用:const enum会在编译时直接内联,性能好,但在使用 babel、isolatedModules 等工具时会有兼容性问题,很多项目干脆禁用const enum。这个知识点可以放进你的“学习笔记”里,等用到了再回来翻。
3. 特殊类型:any、unknown、void、never 与字面量类型
3.1 any和unknown:风险程度完全不同
any是 TypeScript 的“万能逃生口”,但也是最容易被滥用的类型。给一个变量标注any,等同于告诉 TypeScript:别检查我了,我想怎么用就怎么用。这在你从 JavaScript 一点点迁移到 TypeScript 的时候很有用,可以先把类型错误糊弄过去,但长期保留一堆any,就失去了用 TypeScript 的意义。
unknown是比any安全得多的“未知类型”。它表示“我现在不知道这个值是什么”,但它不允许你直接使用。比如:
let data: unknown = fetchSomeData(); // data.name; // 报错:Object is of type 'unknown'你必须先通过类型收窄把unknown变成具体类型,才能访问属性。如果一段数据的类型非常动态,优先用unknown,再用typeof、in等操作符收窄,这样在运行时和编译时都有安全感。
一句话总结:any是放弃类型检查,unknown是保留类型检查但要你“先证明自己的判断”。
3.2 void与never:函数返回值的两种“空”
void常见于声明没有返回值的函数:
function log(message: string): void { console.log(message); }注意,void不表示“返回 undefined”,而是“这里的返回值没有意义”。你可以返回undefined,也可以不返回,类型上都是允许的。
never则更特殊,它表示“永不返回值”。什么情况下是“永不”?函数内部抛出异常,或者进入死循环,或者程序直接退出。比如:
function throwError(message: string): never { throw new Error(message); }never还有一个作用:它是所有类型的子类型,所以你可以用它来检查穷尽性。在switch语句里,如果加了 default 分支并把变量断言成never,TypeScript 就能在你漏掉某种联合类型情况时发出编译错误。这个技巧在写复杂数据状态机时特别有用。
3.3 字面量类型与const的暧昧关系
字面量类型指的是把一个具体的值当作类型来用。比如'up'、42、true都可以作为类型:
let direction: 'up' = 'up';但如果你写let direction = 'up';,TypeScript 会把direction推断成string,而不是'up'。为什么?因为let声明的变量可以被重新赋值,所以 TypeScript 会放宽成string。而const声明的变量不能重新赋值,所以const direction = 'up';会被推断成字面量类型'up'。
这个差异在实际写配置对象时非常关键。比如:
const config = { mode: 'dark', }; // config.mode 的类型是 string,而不是 'dark'如果后面有个函数要求参数必须是'dark' | 'light',你把config.mode传进去就会报错。解决办法是使用as const断言:
const config = { mode: 'dark', } as const; // config.mode 的类型是 'dark'as const很常用,但很多人不理解它到底做了什么,其实就是把对象里所有属性都“冻结”成字面量类型,数组也会变成只读元组。基础类型如果掌握到这里,再看类型系统就会顺畅很多。
4. 联合类型、类型收窄与类型推断:实战中最常用
4.1 联合类型是什么,怎么用
联合类型用|连接多个类型,表示“这个值可以是这些类型中的任意一个”。比如:
let id: string | number; id = 'abc123'; id = 123;联合类型很强大,但有一个关键点:你不能直接调用所有类型的共有方法之外的方法。比如string | number类型的变量,你直接调用toUpperCase()会报错,因为number上没有这个方法。你必须先做类型收窄。
这个限制不是 TypeScript 故意刁难你,而是它站在“运行时可能出错”的角度提醒你:在没确认具体类型之前,不能假设值一定能调用某个方法。这种思维和 JavaScript 的“写完再说”完全不同,但习惯了之后,你能在早期避免大量运行时错误。
联合类型最经典的用法是“可空”场景:string | null、User | undefined。配合strictNullChecks,你写代码的时候会更容易意识到某个值可能为空,然后主动判断。
4.2 类型收窄:把宽泛范围缩小到精确判断
类型收窄指的是通过某种判断,让 TypeScript 在代码分支里自动缩小类型范围。常见方式有以下几种:
typeof判断原始类型:typeof value === 'string'instanceof判断类实例:value instanceof Datein判断对象属性:'name' in value- 数组判断:
Array.isArray(value) - 自定义类型守卫:
function isUser(value: unknown): value is User { ... }
举个例子:
function handleInput(input: string | number) { if (typeof input === 'string') { console.log(input.toUpperCase()); } else { console.log(input.toFixed(2)); } }在if分支里,TypeScript 知道input一定被收窄成了string或number,因此可以安全调用对应方法。
类型收窄的核心是“让数据的可能性随着代码执行逐步收敛”。这比写一堆!非空断言靠谱多了,因为非空断言本质上是你自己在拍胸脯:“相信我,这里一定不是空”。但如果判断逻辑错了,运行时还是照样崩。能用收窄就用收窄。
4.3 类型推断:不写注解时,TypeScript在做什么
有时候你不写类型,TypeScript 也会根据赋值内容推断出类型。如果你写let count = 42;,它推断成number;如果你写const msg = 'hello';,它推断成'hello',这在前面的字面量类型里说过。
更复杂一点的是对象和数组的推断。比如const user = { name: '张三', age: 18 };,TypeScript 会推断出{ name: string; age: number }这个对象类型。当你把对象传进函数时,它会检查属性是否兼容。
类型推断也有“过度推断”的坑。比如:
const arr = [1, 2, '3'];TypeScript 会推断成(string | number)[],这是符合逻辑的。但如果你希望它固定成[number, number, string],就需要显式声明元组类型,或者用as const。
在工作中,我的习惯是:能推断的就不写,不能推断的、容易因为赋值而变化的边界才写。这样代码读起来更清爽,也不会让类型注解变成噪音。
5. 实操过程中容易踩的坑与排查方法
5.1 tsconfig里的baseUrl弃用提示是啥情况
最近很多人打开编辑器的时候,会看到一条编译提示:
Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
这说的就是tsconfig.json里的baseUrl配置。以前很多项目习惯用baseUrl配合paths做模块别名,比如:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }这样就能用import utils from '@/utils'这类写法。但 TypeScript 官方发现baseUrl经常被误用,而且现代编译器在解析路径时不需要它也能工作,于是决定在 7.0 里彻底移除这个选项。
如果你项目里现在还在用baseUrl,建议尽早处理。处理方式很简单:把baseUrl删掉,paths里的路径改成都指向tsconfig.json所在目录的相对路径:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }这样行为基本不变,同时也不会再收到弃用提示。别看这个点很小,真到了 TypeScript 新版发布那天,项目构建突然报错再去排查,会浪费很多时间。
5.2 类型断言 vs 类型守卫:别一遇到报错就as
类型断言有三种常见写法:
const a = value as string; const b = <string>value; // 在 JSX 中不推荐 const c = value!; // 非空断言类型断言是要谨慎使用的。它相当于你告诉 TypeScript:“我知道这个值的类型,不用你管了。” 但如果你判断错了,编译时可能不报错,运行时却会崩。比如:
const data: unknown = getData(); const name = (data as { name: string }).name;如果data是 null,这行代码会直接抛错误。而用类型守卫的话,至少能做一个运行时判断:
if (data && typeof data === 'object' && 'name' in data) { const name = data.name; }因此我建议:类型断言用于“你比 TypeScript 更清楚类型”的场景,比如明确知道接口返回字段;但如果这个值的可靠性不确定,优先用类型收窄。非空断言!也是同理,能不用就不用,用多了基本等于自己骗自己。
5.3 对象类型:interface、type alias和class怎么选
对象是业务代码里最常见的类型载体,TypeScript 里描述对象类型的方式有三种:interface、type别名、class。
interface:适合描述对象的结构,可以被extends继承,也支持 declaration merging(同名声明自动合并)。type:更灵活,可以定义联合类型、元组、工具类型结果,也可以通过&做交叉类型。但它不能像interface那样重复声明合并。class:除了描述结构,还能提供实现逻辑、实例方法、访问修饰符。
我的建议是:对外 API 的对象结构优先用interface,因为它有更好的错误提示和扩展能力;内部复杂类型、联合类型、需要计算出来的类型,用type;只有当你需要封装实体类和面向对象编程时,才用class。
这里有一个很多人会遇到的疑惑:为什么很多库的类型定义里,type和interface都差不多?其实两者在大多数情况下可以互换,但你要明白各自的边界,面试中也常被问到。你不用纠结谁更高级,按场景选就行。
6. 常见问题速查与学习路线建议
6.1 常见报错速查表
下面这张表是我在实际学习和答疑过程中经常遇到的报错,按问题现象和解决方法整理出来,可以直接保存当参考:
| 报错信息 | 含义 | 解决方法 |
|---|---|---|
Type 'undefined' is not assignable to type 'string' | 变量可能为 undefined,但类型上不接受 | 打开 strictNullChecks,显式处理 undefined 分支 |
Property 'xxx' does not exist on type 'yyy' | 访问了类型上不存在的属性 | 检查对象类型定义,使用类型收窄或补属性 |
Argument of type 'string' is not assignable to parameter of type 'number' | 参数类型不匹配 | 检查函数签名中的类型,确保传参类型正确 |
Object is of type 'unknown' | unknown 类型不能直接访问 | 先做类型断言或类型收窄 |
Expected 2 arguments, but got 1 | 函数缺少必填参数 | 给参数设置默认值,或把该参数变成可选参数 |
Cannot find module '@/xxx' or its corresponding type declarations | 模块路径别名解析失败 | 检查 tsconfig paths 和 baseUrl,确保路径配置和 tsconfig 相对位置正确 |
Option 'baseUrl' is deprecated | tsconfig 使用了即将弃用的 baseUrl | 删掉 baseUrl,调整 paths 为相对路径 |
遇到报错的时候,不要急着复制错误信息去搜索引擎,先自己读一遍错误信息。大多数 TypeScript 报错信息已经写得非常明确了,哪个文件、哪一行、哪个类型不兼容,基本都能定位到。
6.2 学习基础类型的建议
如果你现在刚开始学 TypeScript,我建议按照下面这个路径来,不要一上来就啃类型体操:
- 先把原始类型、数组、元组、枚举看完,写几个小例子,体会一下“类型约束”是什么感觉。
- 然后在实际项目里用 TypeScript Playground 或本地项目,把一个 JS 文件改成 TS 文件,逐步加上类型。
- 重点练习联合类型、类型收窄、字面量类型,这三个是日常业务里每天都在用的。
- 再学 interface 和 type 的区别,学会怎么设计对象类型。
- 最后再看泛型、工具类型、条件类型这些进阶内容。
学习过程中,官方文档依然是首选的参考资料,中文社区的笔记也可以看,但要学会分辨哪些是过时内容。比如网上很多教程还在教baseUrl,这已经是弃用方案了。还有那些让你“把所有变量都加类型”的教程,也不建议照做,因为 TypeScript 的设计哲学是类型推断优先。
我自己的体会是,基础类型阶段最重要的是“练习把运行时的判断迁移到编译时”。你会慢慢发现自己写代码之前,会先想一下这个数据的形状是什么,这个函数可能收到什么类型。这种思维转变,比记住一百个类型语法更有价值。
最后再分享一个小技巧:遇到不确定的类型,先把它标成unknown,然后强制自己用类型收窄去处理,而不是直接as any。这个习惯能让你在不知不觉中提升对类型系统的掌控力。等你习惯了这种写法,再看那些复杂的类型报错,基本不会有畏难情绪了。