最近重新整理了一版 TypeScript 知识点笔记,其实是在给团队做内部分享时,发现自己很多结论是“背下来的”,而不是“想明白的”。索性把类型系统、工程配置、Vue3 里的用法,还有这些年实际踩过的坑从头捋了一遍。这份内容不是官方文档的翻译,也不是八股式速查,更像是我作为写了好几年 JS 再转 TS 的人,把代码里真正会卡住你的那部分讲清楚。适合刚上手 TS 的读者查漏补缺,也适合准备 TypeScript 面试的朋友在面试前快速过一遍重点。
1. 为什么 TypeScript 值得单独做一份笔记
1.1 TypeScript 不是“加了类型的 JS”,而是“带约束的 JS”
很多教程喜欢把 TypeScript 描述成“给 JavaScript 加类型”,这个说法没错,但容易让人低估它的价值。早期我写 TS 时也以为只要给每个变量标上string、number、boolean就够了,真正在项目里用了一段时间后才发现,TS 的核心价值不在于给基础类型做标记,而在于它让“数据结构”成为可以被编译器检查和引用的东西。
举个例子,电商后台里一个订单长什么样,原来是靠注释和人肉记忆传递,现在用类型表达:
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'done' interface OrderItem { skuId: string name: string price: number count: number } interface Order { id: string status: OrderStatus items: OrderItem[] totalPrice: number createdAt: Date }这个Order接口写完之后,所有跟订单有关的函数都不需要再啰嗦地解释参数结构,编译器会替你检查:状态是不是拼错了,totalPrice是不是传了字符串,items里有没有漏字段。这就像在开工前先把施工图定好,而不是边砌墙边改图纸。
1.2 类型是文档,也是重构的安全网
我经常跟人讲一句话:TS 的最大受益者是“未来的自己”。项目写一个月后,再打开当初的函数,没有类型标注的话,光靠变量名去猜参数结构是很痛苦的。有了类型定义,函数签名本身就是一份可执行的文档,IDE 的悬浮提示会直接展示这个函数要什么、返回什么。
更实在的好处是重构成为了“可回滚的冒险”。比如把一个函数从接收单个id改成接收整个对象,原来写 JS 的时候要全局搜调用点,还得担心漏改。在 TS 项目里,改完函数签名,编译一下,所有调用点没有一个能逃过编译器的眼睛。类型检查就像在施工现场拉了一圈警戒线,哪里碰了红线一目了然。
1.3 这份笔记适合谁
这份笔记主要给三类人看:第一类是已经会写 JS、刚接触 TS 不久,想系统梳理一遍基础概念的;第二类是在 Vue 或 React 项目里用 TS 用了几个月,但遇到报错只会用any解决,想搞明白背后的原理的;第三类是准备 TS 相关面试,需要快速过一遍重点题型和容易答错的知识点的。当然了,如果只是写一次性脚本、纯个人小玩具,继续用 JS 完全没问题,TS 的价值本来就在长期维护和团队协作里体现。
2. 先把地基打牢:核心类型系统
2.1 类型推断与显式标注的边界
TypeScript 非常擅长自己猜类型,这也是很多人刚上手时觉得“太顺畅”的原因。const count = 1,TS 自动推断为number;let name = '张三',自动推断为string;async function fetchData()返回的值自动推断为Promise<T>。这些场景不需要你手动标注,写上去反而显得冗余。
但有些地方必须依赖显式标注,不然编译器也猜不出来。最典型的是函数参数:参数是运行时才传进来,没有上下文,不标注类型就会隐式变成any,等于白写 TS。还有空数组const list = [],TS 会推成any[],后面往里塞各种类型它都接受,容易埋雷。更稳妥的写法是const list: string[] = []或者const list: Array<string> = []。
还有一个很容易忽略的场景:外部数据,比如接口返回的 JSON、localStorage里读出来的值、第三方 SDK 回调里给的数据。这些数据 TS 看不到内部结构,手动定义好类型再收回来,是项目里最常见的“防呆”动作。我的习惯是:能推断的尽量不写,但函数参数、外部数据边界这两处绝不偷懒。
2.2 联合类型、交叉类型和可辨识联合
联合类型用|表示“多选一”,交叉类型用&表示“同时满足”。这两个符号看着简单,实际使用中的差别很微妙。比如string | number表示参数既可以是字符串也可以是数字;而A & B表示一个对象要同时具备 A 和 B 的全部属性。交叉类型在组合基础接口时很顺手,但有些人喜欢拿它代替继承,我一般更推荐接口继承,后面细说。
联合类型真正的威力藏在“可辨识联合”里。用一个公共字段作为“标签”,TS 就能在不同分支里自动缩小类型范围:
type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; length: number } function area(shape: Shape) { if (shape.kind === 'circle') { return Math.PI * shape.radius ** 2 } return shape.length ** 2 }这段代码里,circle分支里的shape一定带有radius,square分支里一定有length。你不用自己断言,也不用写if ('radius' in shape)这种绕弯的判断。可辨识联合是写复杂业务状态、表单类型、事件处理时最值得养成的习惯。
2.3 接口和类型别名,到底该怎么选
interface和type都能定义对象结构,很多人纠结怎么选。其实记住几个规律就行:
interface 支持声明合并、扩展更自然。多个代码块里可以重复声明同一个 interface 名称,合并属性;extends一个 interface 出来的对象,继承关系清晰。type 能做联合类型、交叉类型、条件类型、映射类型这些 interface 做不了的组合。比如type Nullable<T> = T | null,这个Nullable只能靠 type 表达。还要注意,type 的交叉在类型冲突时会产生never字段,排查起来比 interface 继承麻烦一点。
| 对比项 | interface | type |
|---|---|---|
| 对象结构声明 | 支持 | 支持 |
| 联合类型 / 交叉类型 | 不支持 | 支持 |
| 声明合并 | 支持 | 不支持 |
| 继承扩展 | extends关键字 | 交叉& |
| 条件类型 / 映射类型 | 不支持直接表达 | 支持 |
我的项目经验是:定义对象形状优先用interface,遇到联合类型、工具类型、函数重载这类需求再用type。这个规律不是严格的语法要求,而是进入团队协作后降低认知成本的通用约定。
2.4 泛型:把“写死”变成“写活”
泛型是所有 TS 初学者的一道坎。它的核心思想是“类型参数化”:函数或类型不用固定死具体类型,而是留一个占位符,由调用方在使用时确定。最简单的例子:
function first<T>(arr: T[]): T | undefined { return arr[0] } const num = first([1, 2, 3]) // number const str = first(['a', 'b']) // stringfirst不关心数组里装的是什么,它只保证返回值的类型与数组元素类型一致。这个能力让工具函数既能复用,又能保持类型安全。
泛型还可以加约束。比如我要写一个“按指定 key 对数组建 Map”的工具函数,参数里的 key 必须是真的存在于对象上的字段:
function toMap<T, K extends keyof T>(items: T[], key: K): Map<T[K], T> { return new Map(items.map(item => [item[key], item])) }K extends keyof T表示 K 必须是 T 的属性名之一。调用时如果传错 key 名,TS 会直接报错,不会等到运行时报undefined。这一套弄明白之后,泛型就从“语法”变成了“设计工具”。
3. 进阶类型:从能用变成会设计
3.1 类型守卫
类型守卫的目的是把不确定的类型缩窄到确定的类型,让代码块里的类型推断更精确。平时最常见的写法有四种:typeof、instanceof、in和自定义类型谓词。
typeof适合原始类型,typeof value === 'string'之后,value在分支里就被视为string。instanceof适合类实例,instanceof Date、instanceof Error都是常规操作。in适合对象上的属性判断,if ('radius' in shape)能直接判断某个可选属性是否存在。自定义类型谓词是用value is Type语法,把“如何判断一个值是某种类型”封装成可复用的函数:
function isString(value: unknown): value is string { return typeof value === 'string' }如果不用类型谓词,这个判断函数返回的只是boolean,TS 不会因为你在if里调用它就自动缩窄类型。加了value is string,TS 才能明白“当这个函数返回 true 时,value 确实是 string”。这个细节是很多人写过自定义守卫之后依然报错的根源。
3.2 keyof、索引访问与索引签名
keyof取的是“对象类型的所有属性名组成的联合类型”。这个能力很基础,但配合泛型之后特别好用。比如想写一个“安全获取对象属性”的函数:
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key] }这里T[K]是索引访问类型,表示“取 T 中 K 这个属性对应的值类型”。keyof T保证 K 不会越界,T[K]保证返回值的类型不会丢。这两兄弟总是一起出现,是映射类型、条件类型的地基。
索引签名是对“未知属性集合”的类型描述。比如处理接口返回的字典结构:
interface Dict<T> { [key: string]: T }它表示这个对象可以有任何字符串键,值的类型统一是T。索引签名有两条容易踩的规则:值类型要兼容所有已知属性,比如number类型的age和string类型的[key: string]放一起会报错;同时[key: string]会吞掉所有字符串类型的属性键,设计时要提前想清楚。
3.3 内置工具类型是最高效的“轮子”
TS 自带了一批工具类型,能省掉大量重复的类型声明。我列一个高频清单:
| 工具类型 | 作用 | 示例 |
|---|---|---|
Partial<T> | 所有属性变为可选 | Partial<Config> |
Required<T> | 所有属性变为必选 | Required<Config> |
Readonly<T> | 所有属性变为只读 | Readonly<Point> |
Pick<T, K> | 从 T 中挑选部分属性 | `Pick<User, 'id' |
Omit<T, K> | 从 T 中排除部分属性 | Omit<User, 'password'> |
Record<K, T> | 以 K 作为键、T 作为值构造对象 | `Record<'a' |
Exclude<T, U> | 从联合类型 T 中剔除 U | `Exclude<'a' |
Extract<T, U> | 从联合类型 T 中保留 U | `Extract<'a' |
ReturnType<T> | 提取函数返回类型 | ReturnType<typeof fn> |
Parameters<T> | 提取函数参数元组类型 | Parameters<typeof fn> |
Pick和Omit是最常用的两个,比如接口返回了很多字段,前端只要其中几个,直接Pick<User, 'id' | 'name'>就拿到了一个子集。ReturnType在传函数引用给第三方库时特别有用,不用手写函数返回值类型,TS 自己会抽出来。
3.4 条件类型和 infer,高阶玩家的两个武器
条件类型的语法长这样:T extends U ? X : Y,意思是“如果 T 能够赋值给 U,类型就是 X,否则是 Y”。看起来像三目运算符,但它操作的是类型而不是值。有了它就可以做类型层面的分支判断。
infer是配套使用的“类型抽取”利器,它允许你在条件类型的分支里声明一个待推断的类型变量。一个经典例子是手写ReturnType:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never type Fn = (a: number, b: string) => void type Result = MyReturnType<Fn> // voidinfer R表示“如果 T 是一个函数类型,把函数的返回值抽出来命名为 R”,然后直接返回 R。这种写法读起来有点绕,但从这个例子可以看出infer的本质:它不是你自己定义的固定类型,而是从现有类型里“推断”出来的变量。看到库源码里出现infer U,不要慌,先找它到底想从哪个结构里抽什么出来。
3.5 我为什么不建议所有人上来就“卷类型体操”
网上有很多“类型体操”挑战,一个DeepPick能写好几屏,确实能练基本功。但从真实项目角度讲,业务代码里 90% 以上的类型需求用基础类型、泛型、工具类型就能解决。复杂的条件类型和infer更多出现在库的类型定义里,你自己写业务需要手搓这种体操的机会真不多。
我建议把条件类型和infer学到“能读懂、能模仿写”的程度就够了,不必追求每道题都搓出最优解。真正该花时间的是把基础类型、可辨识联合、泛型约束这些练熟,它们才是每天都会碰到的工具。面试如果问到,能流畅解释ReturnType的infer实现原理,一般就足够证明你有这个能力了。
4. tsconfig 与工程配置:里面真的藏着不少门道
4.1 先看几个影响全局的编译选项
tsconfig.json 是整个 TS 工程的“交通规则”,很多同学平时不怎么碰它,直到报错才打开。有几个选项值得花时间理解。
target决定编译后的 JS 语法版本,比如ES2015、ES2020。它影响 async/await、可选链这类语法是否需要被降级转译。module决定模块系统,ESNext适合现代打包器,CommonJS适合 Node 老项目,Node16或NodeNext适合现代 Node 项目。
strict是最重要的开关,没有之一。开启后它会附带启用strictNullChecks、noImplicitAny等一系列严格检查。我一直建议新项目无脑开启strict,老项目也尽量往这个方向迁移。很多人抱怨 TS“迁不动”就是因为strict开着报错太多,但那些报错恰恰是之前代码里埋下的隐患。
4.2 moduleResolution,以及“node10 已弃用”这个警告
如果你最近启动过一个较新的 TS 项目,可能会在终端里看到这么一条警告:选项moduleResolution=node10已弃用,并将停止在 TypeScript 7.0 中运行。
这里有个背景要讲清楚。历史上,TS 的模块解析默认值叫node,它模拟的是 Node.js 早期(大概 Node 10 时代)的解析规则:先按相对路径找文件,找不到就尝试加.ts、.tsx、.d.ts扩展名,再不行就去找node_modules里的包。这套规则在很长时间里是事实标准。
但后来 Node 自己进化了,引入了 ESM 的exports字段和新的解析语义,TS 也推出了node16、nodenext两种新的解析模式。旧规则和新规则差异太大,TS 官方干脆把旧值改名为node10,并计划在新版本里彻底移除。所以如果你的 tsconfig 里写着"moduleResolution": "node"或"node10",编译时大概率会看到弃用警告。
解决方式不复杂,看项目形态:如果是 Vite 或 Webpack 这类打包器项目,"moduleResolution": "Bundler"是最合适的选择;如果是 Node 服务端项目,用"moduleResolution": "Node16"或"NodeNext",同时把module也改成对应值。只有一种情况还值得继续用旧规则,就是维护一个非常老、完全无法升级依赖的存量项目。
4.3 baseUrl 已弃用,paths 的正确玩法
和moduleResolution差不多时间出现的,还有baseUrl的弃用警告。以前为了让路径别名生效,通常会在 tsconfig 里这么写:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } }baseUrl指定了所有非相对模块导入的基准目录,paths再在这个基础上做映射。于是import xxx from '@/utils'就会指向src/utils。这个写法用了很多年,但在较新的 TS 版本里同样会被标记为弃用,原因是paths本身已经可以不依赖baseUrl独立工作了。
新版推荐写法是直接删掉baseUrl,保留paths,并且把paths里的路径改写成“相对于 tsconfig 文件所在位置”的形式:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }删除baseUrl之后,所有路径映射都以 tsconfig 所在目录作为起点,行为反而更确定。如果项目里同时配了 Vite 的 alias,两边映射要保持一致,否则 TS 编译不报错但 Vite 运行时会找不到模块,这类“能编译但跑不起来”的问题排查起来特别费劲。
4.4 一个可以直接抄的 tsconfig 模板
如果你用 Vite 初始化一个 Vue 或 React 项目,脚手架生成的 tsconfig 一般已经合理。但如果是从零搭 Node 工具或自己组装前端工程,下面这个模板可以作为起点:
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "jsx": "preserve", "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "noUnusedLocals": true, "noUnusedParameters": true }, "include": ["src"], "exclude": ["node_modules", "dist"] }这个配置有几个选项值得解释。esModuleInterop让 ESM 和 CommonJS 之间的导入导出更加顺手,在import React from 'react'这类场景少了很多烦恼。skipLibCheck跳过.d.ts文件内部的类型检查,可以极大减少第三方包之间的类型冲突,我基本默认开着。noUnusedLocals和noUnusedParameters会把未使用的变量和参数直接报成错误,逼着代码保持干净,让我这种强迫症非常舒服。
5. 实战落地:Vue3 + TypeScript 怎么用才顺手
5.1 ref、reactive 与 computed 的类型推导
Vue3 的响应式 API 和 TS 配合得相当好,大多数时候你不需要手动标注,类型能自动推导。ref(0)的value是number,ref('')的value是string,reactive({ name: '张三', age: 18 })推导出对应的对象结构。computed也一样:
const count = ref(0) const double = computed(() => count.value * 2)double的类型被推导为ComputedRef<number>,模板里直接用double就行。需要显式标注的场景主要有两种:一种是ref接收外部传入、有可能是 null 的初始值,要显式声明ref<HTMLDivElement | null>(null);另一种是接口返回的数据结构比较复杂,希望先在类型里定好契约,再用ref<T>([])的方式初始化。
5.2 组件 props 和 emit 的类型化
Vue3 的defineProps和defineEmits配合 TS 泛型,可以告别以前那种靠运行时校验猜参数类型的方式。最推荐的写法是纯类型声明:
const props = defineProps<{ title: string count?: number }>() const emit = defineEmits<{ (e: 'update:modelValue', value: string): void (e: 'change', id: number): void }>()这种写法的好处是组件外部使用时,TS 能自动检查父组件传下来的 props 是否符合声明,事件名和参数也会得到校验。需要注意,纯类型声明下,props 的默认值没法直接在defineProps里设置,要用withDefaults单独处理:
const props = withDefaults(defineProps<{ title: string count?: number }>(), { count: 0 })withDefaults不仅让默认值生效,还会自动把对应 prop 的可选性简化掉,这个细节比老式的props对象写法轻量很多。
5.3 三个特别容易犯的错误
第一个错误是模板里用any解决问题。Vue 的模板表达式做类型检查本来就是“尽力而为”,有时候在模板里访问一个不存在的属性,IDE 不一定立刻标红。别因此觉得可以放飞自我,类型漏洞最后都会在某个深层逻辑里炸出来。
第二个错误是 emit 的参数全部标成any。有些同学图省事,把defineEmits写成(e: string, ...args: any[]),等于把事件的契约完全丢掉。哪天重构业务字段时,父组件和子组件各自不知道对方改了什么,问题就来了。事件参数和 props 一样,应该用具体类型表达清楚。
第三个错误是解构 props 后丢失响应式。const { title } = props取到的只是当前值,不是响应式的。如果确实需要在子组件里单独处理某个 prop,要么直接用props.xxx,要么用computed(() => props.xxx)包装一层。这个坑在 Vue3 刚出来时骗过很多人,现在依然层出不穷。
6. 遇到类型报错,我是怎么排查的
6.1 报错信息其实是一条“线索链”
TS 的报错信息乍一看很吓人,实际上每一条都在告诉你怎么改。最常遇到的Property 'xxx' does not exist on type 'yyy',意思是某个变量在 TS 看来是yyy类型,而你尝试访问的xxx不在它的属性列表里。这时候要先确认 TS 对变量的类型推断是否符合你的预期,不符合就在源头修正类型,别在报错处加as any盖过去。
Type 'undefined' is not assignable to type 'string'这类报错一般是strictNullChecks开着的时候发生的。函数参数可能没传,索引访问可能不存在,TS 会把undefined视为一种“不被允许”的类型。解决思路不是关掉严格检查,而是在源头做判断、给默认值或使用?.、??这类安全访问操作符。
Cannot find module './xxx.vue'在 Vue 项目里很常见,这不是业务代码的问题,而是缺少环境类型声明。需要在env.d.ts或vite-env.d.ts里补充:
declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> export default component }这条规则同样适用于.css、.png等非 JS 资源的导入。记住,报错是线索链,不是麻烦,顺着线索找到源头改,比在报错处打补丁靠谱得多。
6.2 常见问题速查表
| 典型报错 | 可能原因 | 推荐解法 |
|---|---|---|
Property 'x' does not exist on type 'Y' | 类型结构不符合预期 | 检查接口定义,确认联合类型分支或可选字段 |
Type 'undefined' is not assignable to type 'string' | 开启了严格空值检查,值可能不存在 | 用?.、??,或提前判断赋值 |
Parameter 'x' implicitly has an 'any' type | 函数参数未标注类型且上下文无法推断 | 显式给参数标注类型,或补充类型定义 |
Cannot find module 'xxx' | 缺声明文件或路径别名未配对 | 补.d.ts声明,检查 tsconfig 和打包工具的路径映射 |
选项baseurl已弃用 | tsconfig 里还保留着老配置 | 删除baseUrl,改写paths |
选项moduleresolution=node10已弃用 | tsconfig 用了旧的模块解析模式 | 改成Bundler、Node16或NodeNext |
'x' is used before being assigned | 严格模式检测到变量可能未初始化就使用 | 给变量赋初始值或重新梳理控制流 |
这个表格基本覆盖了日常开发里绝大多数报错。遇到没见过的错误,我的习惯是先把报错信息复制进搜索引擎,看官方 issue 和 Stack Overflow 的高赞回答。不是每次都能直接找到答案,但通常能定位到问题方向。
6.3 项目里 TypeScript 编码规范:我踩出来的几条红线
第一,不要用any当万能补丁。any会把类型检查完全关闭,相当于告诉 TS“这里不用你管”,那还不如把这文件改成.js。如果确实不知道类型,优先用unknown,它至少强迫你在使用前做类型收窄。
第二,接口和类型的命名统一风格。我一般用 PascalCase(大驼峰)命名类型,比如OrderItem、UserProfile,不使用I前缀,比如IOrder这种风格在 C# 里常见,但在 TS 社区并不主流,避免制造不必要的认知负担。普通变量用小驼峰,类型和接口用大驼峰,这条约定简单且好用。
第三,优先构造不可变数据结构。能写成readonly的字段就写成readonly,能用Readonly<T>包一层就包一层。类型层面的只读约束在运行时不一定真的冻结数据,但至少能在编译阶段拦下一波误操作。团队协作时,一个字段能不能改、改了会有什么风险,从类型上直接就能看出来。
7. TypeScript 面试:被问最多的几个点
7.1 我把面试里高频问题分成了四类
面试里 TS 的考察点其实很集中。第一类是基础概念,比如interface和type的区别、any/unknown/never/void各代表什么、enum的使用场景和坑。第二类是类型编程能力,比如keyof和typeof的区别、联合类型和交叉类型、泛型约束怎么写、如何用infer获取函数返回值类型。第三类是实际工程问题,比如strict模式包含哪些检查、模块解析的几种模式区别、声明文件在哪里配。
第四类最容易被忽视,就是“类型体操题”,面试官会当场让你写一个工具类型,比如实现Pick<T, K>、Omit<T, K>的原理。这类题不是考背诵,而是考你对映射类型、条件类型和keyof的熟练度。我给的建议是:基础概念必须滚瓜烂熟,类型编程题至少能手写常用工具类型的核心实现,工程配置题结合自己项目里的 tsconfig 说一说就好。面试官想听到的往往不是标准答案,而是你在真实项目里的取舍理由。
7.2 给从零开始的人一条不走弯路的学习路径
如果你现在 TS 基础比较薄弱,我的建议是别一上来就看那种几百页的电子书,容易放弃。先看官方文档里最核心的几个章节,了解基础类型、接口、泛型、类型推导这些概念。官方文档写得清晰,但内容量很大,可以挑着看,不用从头啃到尾。
然后做一遍 Type Challenges(type-challenges),这是个社区项目,里面收集了很多类型练习题,从 easy 到 hard 分级。你会发现很多题自己写不出来,但看题解的过程本身就是最好的学习。之后可以找一两个自己熟悉的第三方库,比如 Vue、lodash-es、axios,直接去读它们的.d.ts文件或者类型定义源码,看它们是怎么设计复杂泛型的。最后找个日常小工具,比如一个请求封装、一个存储封装,用 TS 完整写一遍,踩过几个坑、翻过几次源码,基本就算入门了。
我自己写 TS 的体感是:接触第一天会觉得它“处处跟你作对”,写得很难受;坚持一两周后,你会发现编译器的报错开始变成你的“私人代码审查员”,逼你考虑边界情况、理顺数据结构。磨刀不误砍柴工,把这份笔记里的基本盘练熟,剩下的大量细节都是在项目里边用边查、慢慢沉淀下来的。