从JS项目里跳进TypeScript,是我做前端第五年才下定决心做的事。当时手里的业务代码接近十万行,每次改一个接口字段,都得全局搜索三四个小时,还总有漏网之鱼,等到用户反馈了才发现某个页面崩了。换成TypeScript之后,这类“因为类型写错而爆炸”的问题,在编辑器里就提前被红波浪线拦住了。如果你也处在“JS写得挺熟,但项目一复杂就总出类型事故”的阶段,这篇笔记应该能帮你少走很多弯路。
这篇文章不打算把TypeScript官方文档里的所有特性都搬一遍,而是按我从实际项目里转化过来的顺序来盘:环境怎么搭、核心类型怎么用、interface和type怎么选、泛型怎么理解、.d.ts声明文件怎么读怎么写,以及JS项目迁移时最容易踩的坑。学完这套,你在日常开发里遇到的八成TS场景都能自己搞定,面试问到的核心点也基本覆盖了。
1. 先搞清楚:TypeScript 到底解决了 JS 的什么问题
1.1 JS 最深的坑:类型要到运行时才暴露
我们写JS时都有过这种经历:一个函数接收两个参数,本来传数字,结果某个调用处传了字符串,函数内部一相加,变成了字符串拼接,页面上的金额直接显示成“100200”。更难受的是,这种bug在开发环境根本看不出来,测试的时候也不一定触发,偏偏上线后某个特定操作踩到了。
底层原因就一句话:JavaScript是动态类型语言,变量是什么类型,只有在代码执行到那一行时才能确定。这意味着你没法在“写代码”这个最早的阶段发现类型不匹配,只能靠运行时去撞。小项目还好,代码量上来之后,函数之间互相调用,数据层层传递,任何一层的返回值类型变了,下游所有用到它的地方都可能跟着出错。
TypeScript的核心价值,就是把这个“运行时猜谜”改成“开发期体检”。它给变量、函数参数、返回值都加上类型标注,在代码编译成JS之前,先做一次全量类型的静态检查,把所有类型不匹配的地方揪出来。这样你写代码时面对的不再是一堆“可能出错”的隐患,而是一套有边界的规则。
1.2 TypeScript 不是新语言,而是 JS 的“体检报告”
很多人第一次接触TS会误以为要学一门全新的语言,其实不是。TypeScript是JavaScript的超集,任何一个合法的JS文件,只要改一下扩展名变成.ts,都能被TS编译器解析。它只是在JS原有的语法上,增加了一套“类型系统”和对应的编译工具。
打个比方,JS像是一个医生凭经验看病,只有症状出现时才知道哪里有问题;TS则是先让你做一遍全套体检,把血压、血糖、肝功能都量好,再用这些指标来判断风险。但体检不会改变你的身体,TS也始终只负责“检查”,等编译到最后,所有类型都会被擦除,输出的是干净、纯正的JS代码。
所以你必须先建立一个认知:TS的类型系统只活在开发期,运行时完全不存在。这也解释了为什么TS没有牺牲任何JS的运行性能,因为它根本不参与运行时逻辑。理解了这一点,后面看到“类型断言”“映射类型”这些概念,就不会把它和JS运行时行为混在一起。
2. 环境搭建:从 tsc 到 tsconfig,一次配好
2.1 三分钟装上 TypeScript,跑通第一个 .ts 文件
搭建环境没什么玄学。装Node之后,我用全局安装的方式先搞定编译器:
npm install -g typescript装完验证一下版本:
tsc -v能输出版本号,就算成了。接着建一个test.ts,写上一行:
const greet: string = "hello ts";用tsc直接编译:
tsc test.ts同级目录会多出一个test.js文件,打开看会发现类型标注已经被抹掉了。第一次亲眼看到类型擦除这个过程,比看十篇文档都有用。
不过实际项目里很少会直接tsc编译单个文件,更多是用构建工具集成。新建Vite项目时选TypeScript模板,脚手架会帮你配置好一切的编译链路。如果是在现有项目里接TS,建议按“tsc做类型检查 + 构建工具做转译”的分工来理解,二者不冲突,前者管质量,后者管效率。
2.2 tsconfig.json 这几个配置,建议每一行都看懂
项目根目录下执行tsc --init会生成一份tsconfig.json,里面注释比代码还多。真正天天影响你的配置其实就那么几个。
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "strict": true, "outDir": "./dist", "rootDir": "./src", "include": ["src/**/*.ts", "types/**/*.d.ts"], "exclude": ["node_modules", "dist"] } }target决定编译成哪个版本的ES语法,module决定模块规范。strict建议一打开就开着,它聚合了noImplicitAny、strictNullChecks等一堆严格检查选项。严格模式下的TS会逼着你把类型写清楚,刚开始觉得烦,但项目上了规模,你会感谢这个选项帮你从源头拦截掉“隐式any”这种偷懒写法。
outDir和rootDir是编译输出的目录映射,include和exclude则控制编译器到底处理哪些文件。这里重点提示:include不仅包含src下的.ts,还需要包含专门的types目录,如果你在项目里放了自定义声明文件,漏掉这条,TS会直接忽略它们,然后给你报一堆找不到类型的错误。这个坑我踩了不止一次,后面讲.d.ts时会细说。
2.3 现代前端项目怎么接 TS:Vite 与 Nuxt 场景
如果你用的是Vite,那基本不用纠结tsc的配置,因为开发环境下Vite直接esbuild转译TS,类型检查交给vscode插件和构建时的vue-tsc(以Vue项目为例)。但我要提醒一件事:Vite只转译不做类型检查,所以你会发现有些类型错误在dev server下根本不报错,只有执行vue-tsc --noEmit做构建检查时才蹦出来。
Nuxt、Next这类服务端渲染框架对TS支持也都很完整,新手不需要自己从零配. d.ts,框架自带类型会帮你把页面、组件的props推导得明明白白。我的建议是:练手别在环境搭建上花太多时间,直接拿Vite脚手架起一个TS项目;真正需要手写配置,往往是你开始给老的JS项目补TS支持,或者维护纯TS的npm包时。
3. 类型系统的核心:从 let a: number 到泛型,逐个吃透
3.1 基础类型与类型推断:先戒掉过度标类型的坏习惯
TS基础类型列表并不长:string、number、boolean、null、undefined、symbol、bigint,以及对象、数组、元组、枚举、any、unknown、never。新手最容易犯的错,是给每个变量都写全类型标注,比如let count: number = 1。
实际上TS有很完善的类型推断机制,像这种字面量初始化,编译器一眼就能看出count是number,你写标注纯属多余。正确的习惯是:能推断出来的不写,推断不出来的才标。判断标准很简单——如果删掉类型标注,编辑器报错了,那就是需要写;不报错还写着,就是冗余。
需要显式标注的地方主要有几类:函数参数必须有类型(否则就是隐式any),函数返回值建议标(尤其是返回逻辑复杂时),以及空数组、空对象这类初始化后类型会被推断为any的场景。基础类型里我最想强调的是any和unknown的区别。any是彻底放弃检查,让类型回到JS的裸奔状态;unknown是“我知道这里可能是任意值,但使用时必须先做判断”。能用unknown就不要用any,这是和团队协作时的安全底线。
3.2 interface 到底怎么继承?和 type 有什么区别
interface是TS里描述对象结构的主力。定义一个用户对象:
interface User { id: number; name: string; email?: string; readonly createdAt: Date; }?表示可选属性,readonly表示只读属性,这两个点平时很常用。interface支持继承,而且支持多继承:
interface Admin extends User { permissions: string[]; }继承了User的Admin,自动带上id、name、email、createdAt,再追加permissions。这个继承的语义和JS里的类继承很接近,很多初学者会把interface和class搞混,其实interface只是描述数据的形状,不产生任何运行时代码,class则真正定义了一个可实例化的结构。
和interface最容易混淆的是type类型别名。大多数人都会纠结“我到底该用哪个”。我的经验是:优先用interface来描述对象和类,因为它支持声明合并——同一个名字的interface可以定义多次,TS会自动把它们合并到一起。这个特性在写第三方扩展、给全局对象补类型时特别有用。type则更适合表达联合类型、元组、函数签名这类“不是一个具体对象”的类型,比如:
type Status = "pending" | "success" | "error"; type Callback = (data: string) => void; type Point = [number, number];type也能写对象类型,但一旦涉及联合、交叉类型,或者需要为数字/字符串字面量定义别名,type就是唯一选择。interface继承的替代写法在type里叫交叉类型(A & B),但两者有细微差别,建议日常以interface继承为主,type处理组合类型。
3.3 联合类型、交叉类型与类型守卫:把运行时判断提前
联合类型让你允许一个值在有限几种类型中变化,最常见的场景就是接口字段可能返回数组也可能返回对象。你写type ResponseData = User[] | User,TS就自动帮你在后续每一个使用时做类型收窄的判断约束。
类型收窄最基础的手段是typeof,比如:
function format(value: string | number) { if (typeof value === "string") { return value.toUpperCase(); } return value.toFixed(2); }除了typeof,遇到对象类型时可以用in操作符判断某个属性是否存在,或者直接自定义一个类型谓词函数:
function isUser(item: User | Admin): item is Admin { return "permissions" in item; }item is Admin这个写法就是类型谓词,它告诉TS:这个函数返回true时,传入的参数就是Admin类型。有了它,后面的分支代码里TS能把类型精确收窄到Admin,你就能安全访问permissions。类型守卫是这个阶段最值得练透的能力,因为实际业务里拿着一个联合类型做判断是家常便饭,判断写得对,类型系统才会配合你。
交叉类型和联合类型正好相反,联合表示“或者”,交叉表示“并且”。type FullUser = User & ExtraInfo表示同时具备两边的全部属性。交叉类型在扩展基础类型、组合多个来源的数据时很顺手,我通常在type里需要合并对象时用,interface场景则直接用extends。
4. 泛型:把类型当参数,告别“万能的 any”
4.1 泛型函数:看不懂自己写一遍就通了
泛型这个概念,很多从JS转来的同事一看到就头疼。我用来解释它的一句话是:“你在写代码时还不知道这个值是哪种类型,但你可以把类型本身也当成一个参数,等真正调用时再确定。”这个思路和JS里函数参数很像,只是参数传的是值,泛型传的是类型。
看一个最简单的例子:
function identity<T>(value: T): T { return value; } const str = identity("hello"); // T 推断为 string const num = identity(42); // T 推断为 number同样的一个函数,可以用在字符串上,也可以用在数字上,并且返回值的类型永远和传入值一致。如果不用泛型,你只能用any,然后失去所有类型提示;用了泛型,这个函数就变成了一把“万能但精确”的钥匙。
学会泛型后,最值得掌握的是泛型约束。你不希望T真的是任意类型,而是希望它“至少具备某些属性”,于是加一个extends约束:
function getLength<T extends { length: number }>(arg: T): number { return arg.length; }现在调用getLength时,传入的必须是一个有length属性的参数,字符串、数组都能过,数字直接报错。这种约束在写通用工具函数时极其常见。
4.2 泛型接口与内置工具类型:你每天都在用的 Partial
泛型不止用于函数,也广泛用于接口。最典型的场景是封装接口响应结构:
interface ApiResponse<T> { code: number; message: string; data: T; } const res: ApiResponse<User> = { code: 0, message: "ok", data: { id: 1, name: "张三" } };这样只要声明ApiResponse ,data字段自动就是User类型,而不用为每个接口写一个单独的Response类型。Promise.resolve的返回值、后端列表分页、资源池等场景的泛型封装思路都一样:让类型跟着数据结构走,而不是写死。
掌握泛型基础后,一定要认识几个内置工具类型,它们本质都是泛型操作。Partial 把T的所有属性变成可选,在更新接口时常用;Pick<T, K>从T中挑选一部分属性;Omit<T, K>反过来剔除一部分属性;Record<K, V>快速构造一个“键为K、值为V”的对象类型;ReturnType 取出函数的返回类型。这几个工具类型能显著减少重复类型定义,比如“编辑用户”和“创建用户”的区别,往往就是“所有属性必填”和“部分属性可选”,一个Partial就解决了。
5. .d.ts 类型声明文件:从读懂了到自己写
5.1 什么场景需要 .d.ts:拿到一个没有类型的 JS 库怎么办
TS项目里常出现一行刺眼的红色报错:“Could not find a declaration file for module 'jquery'”。意思很简单:代码里import了一个JS库,但TS找不到它的类型描述。这时你有两条路,一是装社区维护的类型包,比如@types/jquery;二是找不到现成类型时,自己写一个声明文件。
.d.ts文件本质上就是“给别处写好的JS加一份类型说明书”。它的后缀里有个d开头,就是declaration的意思。在这类文件里,你只能写类型声明,不能写逻辑代码,因为TS不会编译它输出任何JS,它只是给编译器看的。
还有一种常见的自建场景:团队的公共JS模块还是老代码,你没空把整个模块改成TS,但又希望TS项目引它时有类型提示。这时在项目里新建一个.d.ts,用declare把模块的类型描述出来,TS就能和这个JS模块愉快协作。我自己接过一个老项目,就是靠一批.d.ts文件,让占总量一半的旧JS代码在新TS代码里引用时完全不报错,迁移压力小了很多。
5.2 .d.ts 文件怎么写:declare 灵魂三问
写.d.ts先记住一个关键字:declare。它的意思是“我声明一个东西存在,但它的实现在别处”。三个最常用的场景如下。
声明模块类型:
declare module "my-old-lib" { export function foo(arg: string): number; export const version: string; }声明全局变量:
declare global { interface Window { __INITIAL_STATE__: Record<string, unknown>; } }这段代码把window对象上新增一个__INITIAL__STATE__属性,并且标了类型,之后在全局就能安全访问window.__INITIAL__STATE__。
声明函数和类也是同一套套路,用declare function或declare class开头,后面跟完整的类型签名。注意.d.ts里如果用了export,那这个模块是“有导出的模块声明”;如果不写export,默认是全局声明,可能污染全局命名空间。我的建议是:工具和库类型一律用模块导出,只有真的需要放在全局环境的东西才用declare global。
5.3 types 文件夹的声明文件到底怎么用
很多人在项目根目录建一个types文件夹,把.d.ts往里面一放,结果发现根本不起作用。原因十有八九是tsconfig.json里的include没覆盖到这个目录。我一直在用的是下面这套配置:
{ "compilerOptions": { "typeRoots": ["./node_modules/@types"], "types": [] }, "include": ["src", "types"] }include里加上"types",编译器才会去读这个文件夹下的.d.ts。typeRoots默认指向node_modules/@types,那是给第三方类型包用的,自己写的声明文件不要塞进去。还有一种方法是直接把.d.ts文件放在src目录里,只要被include覆盖到,同样能生效。关键就一句话:声明文件必须在编译器的扫描范围内,否则写了等于白写。
types文件夹下最常见的戏码是给全局变量、环境变量补类型。比如Vite项目的import.meta.env,或服务端渲染项目挂到window上的全局数据,都可以在types文件夹里集中声明,方便统一管理。团队的公共类型定义也建议统一放在这里,比如我给你列举的热门场景:在一个types/global.d.ts里声明所有环境变量,再在types/api.d.ts里放接口返回结构类型,团队用起来很顺,eslint也不会因为有“类型文件里混着运行时代码”而抱怨。
6. JS 项目转 TS 的实战:类型断言与常见报错排查
6.1 类型断言:as 不是万能钥匙
从JS迁移到TS,你会遇到很多“我比编译器更懂这个类型”的场景。比如后端返回的JSON,你明确知道data是User对象,但它在TS眼里只是any或unknown。这时可以用类型断言:
const user = data as User;as就把data的类型强制指定成User,后续属性访问就有提示了。还有一种更极端的双重断言写法data as unknown as User,一般只在数据源非常可信时使用,因为它绕过了TS的所有检查门槛。
另一个常用的是非空断言,写法是在变量后加感叹号:props.name!,它告诉TS“我确定它不是null或undefined”。这个在模板里处理可能为空的字段很方便,但要克制使用,因为它本质上是你跟编译器“拍胸脯保证”,一旦保证错了,还是会回到运行时崩溃的老路。
我个人对as的态度是:尽量少用,用之前先问自己一句“这里是不是应该有类型守卫,而不是断言”。类型守卫是TS主动帮你收窄的合法途径,断言则是你单方面宣布“我说的就是对的”。能用守卫不用断言,这个习惯能让你少很多隐性风险。
6.2 迁移踩坑实录:JS 转 TS 最常见的 5 个报错
我前前后后帮三个老项目做过JS转TS的迁移工作,踩过的坑基本可以归纳成五类。
第一类,模块找不到类型声明。解决方法就按上一节说的,先找@types包,找不到再自己写.d.ts。第二类,对象属性可能为null或undefined,报strictNullChecks相关红字。处理方法是用可选链?.和空值合并??,同时把类型定义成string | null,而不是用非空断言硬扛。第三类,隐式any。函数参数没写类型、数组取出来元素类型不明确都可能触发。这类报错就安静地把参数类型补上,也算借机梳理一遍代码逻辑。
第四类,某个第三方库的API类型和实际用法对不上。可能是版本不匹配,或者库本身类型定义过时。我的建议是先用as绕过去,同时提一个issue,等官方修;千万别直接any,过一阵没人记得它是谁的锅时,你就得自己调查这个any埋在哪。第五类,strict模式下很多历史写法被判为“不安全”,比如给可能为undefined的变量赋值、或者在回调里没有处理错误分支。处理方式一般是重构代码,拆出更明确的分支逻辑,而不是粗暴放松strict配置。
迁移顺序上,我强烈建议“从依赖图的叶子节点开始”。也就是说,先迁移被大量模块依赖的工具函数、常量定义、公共接口类型,把它们的基础类型铺好,再逐步向业务页面层推进。叶子节点类型稳了,上层迁移时自动就能获得类型推导,报错量少一半以上。反过来先迁页面,你会被一大串依赖链上的类型问题淹没,很容易心态崩溃。
最后分享一点个人的学习节奏
如果让我重新学一遍TypeScript,我会把顺序锁定为:基础类型和interface、type先过一遍;再练类型守卫和联合类型,因为业务里天天用;泛型只要理解“类型当参数”这一个点就够了,工具类型碰到一个查一个;.d.ts不用一开始就深入研究,等真正遇到“无类型库”报错时再针对性学,记忆效果比我当初从头啃文档好得多。
还有一个实操小技巧:在我自己学习期间,会专门挑一个已经写好的JS原生项目,强制自己用TS把它重新写一遍,不追求功能创新,就追求每个函数、每个对象结构都有明确的类型定义。这个动作做完,你对TS的熟悉程度会远超只看文档的同事。代码里的类型就是你的文档,团队协作时,所有人都在你定义好的边界里写代码,这大概就是我从JS走向TS后体会最深的一点。