十年前接手第一个多人协作的前端项目时我还在用纯 JavaScript 写页面。项目不大几十个文件谁改了哪个函数的参数靠群里喊一声就能同步。等代码量涨到几万行、团队从三个人扩到十几个人问题就压不住了一个函数明明声明了五个参数调用处少传一个浏览器一声不吭跑到那一行才炸一个对象字段被重命名全项目几十处引用只能靠全局搜索替换漏掉一处就是线上事故。后来项目里引入了 TypeScript第一次tsc跑下来红了三十多个错误其中有一个是某个接口的userId被写成了uId藏了快半年。那一刻我才真正理解TypeScript 和 JavaScript 的区别远远不只是多了一套类型注解。这篇文章我把这些年攒下来的差异梳理清楚从语言本质到工程落地从日常写法到踩坑排查不管你是刚接触 TS 的新手还是想给团队做技术选型的老手都能拿到能直接用的东西。1. 先把关系理清楚不是替代是超集与派生1.1 浏览器和 Node 只认 JavaScriptTypeScript 最终也要变成 JavaScript这一点必须先说透否则后面全是糊涂账。TypeScript 是 JavaScript 的超集superset。意思是你把一个.js文件直接改名叫.ts绝大多数情况下它是合法的——当然开了strict之后有些写法会被挑刺比如隐式any、函数参数没标类型但这些是规则层面的事不是语法层面的冲突。TypeScript 在 JS 之上加的东西主要就是类型系统外加几个语法糖接口、泛型、枚举、装饰器、命名空间、抽象类。这些语法在编译之后会被擦除type erasure产出的是完全标准的 JavaScript。浏览器读不懂.tsNode 默认也读不懂小程序引擎、Electron 渲染进程都读不懂它们只认.js。注意正因为如此TypeScript 的价值全部发生在开发期和构建期。它是写给开发者以及编辑器和 CI看的不是写给运行时执行的。想通这一层很多怪现象立刻就有答案——为什么编辑器红了一片vite build却照样能成功因为 Vite 用的 esbuild 默认只做转译、不做类型检查类型检查是tsc的活两件事被拆开了。我习惯用一个比喻TypeScript 像是一份带批注和校验规则的施工图纸JavaScript 是照着图纸盖出来的房子。住进去的人只看得到房子看不到图纸。图纸上批注写错了房子照样能盖起来只是可能盖歪而且歪了没人提醒你。1.2 类型到底解决了什么真问题很多刚接触 TS 的人会觉得类型是额外的负担写起来啰嗦运行起来又没区别。这种感受我完全理解因为在小脚本里类型确实带不来什么收益。但项目一上规模它解决的是一类非常具体的成本问题参数对不上。JS 里函数少传参数、多传参数都不报错多传的直接被忽略少传的变成undefined然后在下游某个地方Cannot read property of undefined。TS 在调用点就标红。字段拼写错误。user.uId和user.userIdJS 里前者返回undefined要等运行时才发现TS 里编辑器直接给你划红线还能自动补全。重构找不到所有引用。IDE 的重命名符号功能在 JS 里只能基于文本匹配在 TS 里基于类型信息改一个接口字段所有引用点一次性改完。第三方库接口不明确。以前调一个库得翻文档、翻源码、翻 README有了.d.ts函数签名、可选参数、返回值类型全部在编辑器里直接看到。联合类型的成员访问。type Status idle | loading | done这种写法JS 里只能靠注释约定TS 里字符串写错立刻报错。我一直强调一句话类型不是为了证明程序正确它是把一类低级错误从运行时提前到编译期。编译期发现一个错误的成本是几秒钟运行时发现的成本可能是几分钟线上发现的成本可能是几个小时加一次事故复盘。2. 核心差异逐条拆开讲2.1 静态类型检查 vs 运行时动态类型这是最本质的一条差异。JavaScript 里变量没有类型值才有类型let a 1; a hello; // 完全合法a 现在指向一个字符串 a { x: 1 }; // 同样合法TypeScript 里let a 1会推断出a的类型是number后面赋字符串直接报错。你要是真想让它变化得显式写成let a: number | string。这就是静态检查在代码运行之前先把每个标识符的类型固定下来然后做一致性校验。但这里有个容易被误解的点TS 的检查发生在编译期运行时的行为跟 JS 一模一样。let a: number | string 1编译成 JS 之后那行类型注解就没了剩下的还是let a 1。所以千万别指望类型能防止运行时的数据污染——接口返回的 JSON 里age是个字符串18TS 拦不住除非你在边界上手动校验。另一个必须知道的概念是结构化类型structural typing也就是常说的鸭子类型。TS 判断两个类型兼不兼容看的是形状不是名字interface Point { x: number; y: number } interface Coord { x: number; y: number } const p: Point { x: 1, y: 2 }; const c: Coord p; // 合法形状一致就行这跟 Java、C# 那种名义类型系统完全不同。好处是灵活跟 JS 的动态生态贴合坏处是偶尔会出现我明明没想让它通过的情况尤其是两个业务含义不同但字段恰好相同的接口会被误判为兼容。这也是 TS 里品牌类型branded type这种技巧存在的原因。2.2 编译链路转译和类型检查是两件独立的事很多教程把 tsc 一概称为编译其实里面藏了两个阶段转译transpile把 TS 语法变成 JS类型检查type check做语义校验。这两件事可以分开执行也可以合并执行区别很大。tsc既转译又检查全量处理速度慢但最完整。ts-loader走 tsc 的 API检查 转译配transpileOnly: true可以只转译。esbuild/swc只转译不做类型检查速度快到夸张Vite、Next.js 这类工具默认走这条路。babelbabel/preset-typescript只转译不检查。所以一个成熟项目的标准姿势是构建用 esbuild/swc 图快类型检查单独跑tsc --noEmit放进 CI 卡口。我给团队配的package.json脚本通常长这样{ scripts: { dev: vite, build: tsc --noEmit vite build, typecheck: tsc --noEmit --watch } }实操心得tsc --noEmit是纯检查模式不产出任何文件跑起来比带 emit 略快。本地开发挂--watch让它常驻改完文件几乎立刻能看到错误比等到提交时才发现要舒服得多。另外tsc是单线程的大项目跑一次可能要几十秒实在嫌慢可以上tsc-files或者项目引用project references做增量。还有一个容易踩的点类型擦除后代码结构会变。enum会变成一个 IIFE 对象namespace会变成一层闭包装饰器会变成函数调用。这些都有运行时开销。const enum本来是想避免这个开销但它在isolatedModules模式下不能用跨文件引用也有坑我现在基本不用了直接上as const的对象字面量。2.3 类型能力泛型、接口、联合、装饰器各自解决什么这块是 JS 完全没有的东西。挑几个日常用得最多的说。interface 和 type 怎么选。功能上大部分重叠区别在于interface可以被声明合并同名 interface 自动合并适合描述可扩展的对象形状type能做联合、交叉、条件类型、映射类型这些高级操作表达力更强。我自己的习惯是描述对象结构用interface写工具类型、联合类型用type。泛型是给函数和类装类型参数。最经典的例子function pickT, K extends keyof T(obj: T, keys: K[]): PickT, K { const result {} as PickT, K; keys.forEach(k { result[k] obj[k]; }); return result; } const user { id: 1, name: 张三, age: 28 }; const brief pick(user, [id, name]); // brief 的类型自动推断为 { id: number; name: string }K extends keyof T这个约束是关键它保证你传的键必须在对象里真实存在写成pick(user, [idd])直接报错。JS 里想实现同样的安全级别只能靠运行时手动检查。联合类型加类型收窄是日常写业务代码最爽的地方type Result | { ok: true; data: string[] } | { ok: false; error: string }; function handle(r: Result) { if (r.ok) { console.log(r.data.length); // 这里 TS 知道是成功分支 } else { console.log(r.error); // 这里知道是失败分支 } }编译器靠r.ok这个判别字段自动收窄类型JS 里这种模式只能靠约定写错了没人拦。装饰器和命名空间主要出现在 Angular、NestJS 这类框架里。NestJS 就是一个典型控制器、服务、依赖注入全靠装饰器加类型元数据撑着。Controller(users) export class UserController { constructor(private readonly userService: UserService) {} Get(:id) findOne(Param(id) id: string) { return this.userService.findById(id); } }private readonly userService: UserService这一行既是类型声明也是注入声明——NestJS 通过反射读参数类型来解析依赖。纯 JS 写 NestJS 也能跑但得手动Inject(UserService)啰嗦很多。至于declare global和namespace最常见的用途是给window挂全局变量做声明declare global { interface Window { __APP_CONFIG__: { apiBase: string }; } } export {};没加export {}的话这个文件会被当成全局脚本declare global会报错这个坑我见过太多次了。2.4 生态与工程化真正拉开差距的地方语言层面的差异只是表象工程化能力的差距才是团队最终选 TS 的决定性原因。几个具体表现类型声明文件.d.ts。JS 库想给 TS 用户提供类型有两条路库自己带.d.ts或者社区在 DefinitelyTyped 里维护types/xxx。装一个npm i lodash还得再补一个npm i -D types/lodash以前是标配操作。现在新出的库基本都自带类型了这一点比五年前好太多。tsconfig.json是工程的中枢。strict系列开关、target、module、moduleResolution、paths、lib、skipLibCheck每一项都直接影响体验。团队协作里最常见的争论就是要不要开strict。我的立场很明确新项目一律全开老项目从strictNullChecks开始逐步加因为空值问题造成的线上事故占比太高了。编辑器体验。这可能是最被低估的一条。写 TS 的时候自动补全、跳转定义、查看类型、内联提示、重命名重构全都建立在类型信息之上。写纯 JS 虽然也有 Language Service 支持但准确度和覆盖度差一大截。用惯了 TS 再回去写 JS会有一种闭着眼睛开车的感觉。和框架的结合度。Vue 3 的组合式 API 天生就是为 TS 设计的defineProps、refT、computedT的类型推断做得非常顺React 的函数组件加 Hooks 也几乎没有摩擦NestJS 前面说了装饰器加类型基本就是骨架。前后端分离的项目里后端用 SpringBoot 返回 JSON前端手写一套interface去接虽然两边不会自动同步但至少前端自己有了一道校验墙——接口字段改了前端编译就红比等到页面上显示undefined强得多。3. 实操从零搭一个可跑的环境把日常写法对照一遍3.1 环境准备与依赖安装先确认 Node 版本然后全局或项目内装 TypeScript。我推荐项目内安装版本可控node -v # 建议 18 以上 npm init -y npm i -D typescriptlatest npx tsc --version然后生成配置文件。这里有个小技巧--init生成的默认配置注释一大堆可以配合--init后手动精简npx tsc --init建议同时装tsx或ts-node用来直接跑单文件省得每次都编译一遍npm i -D tsx npx tsx src/index.ts目录结构保持简单project/ ├─ src/ │ ├─ index.ts │ └─ types.ts ├─ tsconfig.json └─ package.json3.2 tsconfig.json 关键项逐条解释顺便说说 baseUrl 弃用的事先给一份我常用的配置{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: bundler, lib: [ES2020, DOM, DOM.Iterable], strict: true, noUncheckedIndexedAccess: true, noImplicitOverride: true, exactOptionalPropertyTypes: true, noEmit: true, skipLibCheck: true, esModuleInterop: true, forceConsistentCasingInFileNames: true, isolatedModules: true, resolveJsonModule: true, paths: { /*: [./src/*] } }, include: [src] }逐条说下为什么这么选target: ES2020决定产出 JS 的语法级别也决定哪些内置类型能直接用。太低会大量降级语法太高老环境跑不了。ES2020 是当前安全区间。moduleResolution: bundler给打包器用的解析策略允许省略扩展名、支持package.json的exports字段。strict: true一把开启所有严格检查的总开关包括strictNullChecks、noImplicitAny、strictFunctionTypes等。新项目必开。noUncheckedIndexedAccess这个默认是关的开了之后arr[0]的类型会变成T | undefined强制你处理越界。刚开的时候会红一片但能挡掉大量运行时错误。skipLibCheck: true跳过node_modules里.d.ts文件的检查。这个必须开否则第三方库之间的类型冲突能让你怀疑人生而且检查那些文件对你自己代码的正确性没有任何帮助。isolatedModules: true保证每个文件都能被独立转译esbuild、swc 这类逐文件处理的工具需要它。重点说baseUrl的弃用。以前配路径别名标准写法是这样{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }paths里的路径是相对于baseUrl解析的。TypeScript 从 5.9 起开始对baseUrl给出弃用提示并计划在 7.0 里彻底移除。迁移方式很简单去掉baseUrl把paths的值改成相对tsconfig.json所在目录的相对路径{ compilerOptions: { moduleResolution: bundler, paths: { /*: [./src/*] } } }注意前面那个./不能省这是新手最容易漏的地方——省了会报找不到模块。另外别忘了打包器那边也要同步配一份别名Vite 里配resolve.aliasWebpack 里配resolve.alias两边要一致否则 TS 不报错但打包失败或者反过来。踩过的坑别名只配在tsconfig.json里、忘了配打包器本地开发因为tsx走的是 TS 的解析逻辑所以一切正常一上线打包就Module not found。这个坑我在两个项目里都遇到过后来直接在 CI 里加了npm run build作为必过项问题才彻底消失。3.3 几个日常操作JS 和 TS 写起来差在哪合并两个对象。JS 里最常用Object.assign和展开运算符const a { name: 张三, age: 28 }; const b { city: 杭州 }; const merged { ...a, ...b }; // merged: { name, age, city }TS 里这么写完全没问题类型会自动推断成{ name: string; age: number; city: string }。但有一个坑后面对象的同名属性会覆盖前面TS 只按最终结果推断类型。如果b里有个age: 28的字符串类型会变成string而不是报错。想拦住得写泛型约束function mergeT extends object, U extends object(a: T, b: U): T U { return { ...a, ...b }; }但T U在同名属性类型冲突时会产生never这才是你想要的效果。实践中我更推荐SpreadT, U这类工具类型社区里有现成实现能正确处理覆盖关系。通过字符串调用函数。JS 里很简单const handlers { save: () console.log(保存), cancel: () console.log(取消), }; const name save; handlers[name](); // 正常执行TS 里这么写会报错Element implicitly has an any type because expression of type string cant be used to index。因为name是宽泛的string编译器不知道它一定是合法的键。三种解法// 方案一约束键的类型 const name: keyof typeof handlers save; handlers[name](); // 方案二加索引签名 const handlers: Recordstring, () void { /* ... */ }; // 方案三运行时校验 类型守卫 function isHandlerKey(k: string): k is keyof typeof handlers { return k in handlers; } if (isHandlerKey(name)) handlers[name]();方案一最干净方案三最安全。我一般优先方案一动态字符串来自外部输入时才用方案三。DOM 操作。这段 JS 代码应该很多人写过const v document.querySelector(video); v.style.rotate -90deg;TS 下querySelector的返回类型是Element | null两个问题一是可能为null二是Element上没有style。正确写法const v document.querySelectorHTMLVideoElement(video); if (v) { v.style.rotate -90deg; }泛型参数HTMLVideoElement让返回类型精确到视频元素。如果你用的 TypeScript 版本较旧CSSStyleDeclaration里可能还没有rotate这个属性会报Property rotate does not exist两个办法升级 TS 拿到新版lib.dom.d.ts或者用v.style.setProperty(rotate, -90deg)绕过。我一般选前者后者会把拼写错误也一起绕过去。空数组的类型陷阱。这个坑挺隐蔽const list []; // 推断成 any[] list.push(1); list.push(a); // 不报错const list []在宽松配置下推断为any[]等于放弃了所有保护。正确做法是显式标注const list: string[] []; // 或者 const list [] as string[];还有一种写法let obj [{}]推断出来是{}[]往里面推对象时几乎全是any级别的约束形同虚设。碰到这种一定要显式写const list: Array{ id: number } []。原型链那点事。有人问过JS 里没 new 完的对象为什么能用 prototype这里的核心是new的过程中第一步就是创建新对象并把它的[[Prototype]]指向构造函数的prototype然后才执行构造函数体。所以即使构造函数执行到一半抛出异常那个已经创建并挂好原型链的对象在异常被捕获的上下文里依然能访问原型上的方法。TS 对这套机制的态度是可以描述但不鼓励绕开类型系统去动态改原型。想扩展原型正规做法是声明合并declare global { interface ArrayT { firstOrNull(): T | null; } } Array.prototype.firstOrNull function T(this: T[]): T | null { return this.length 0 ? this[0] : null; };这比Array.prototype.xxx ...裸写安全得多至少类型层面是通的。3.4 在 Vue、NestJS 这类真实项目里怎么落地Vue 3 加 TS 是当前国内团队最常见的组合。几个高频写法值得记一下script setup langts import { ref, computed } from vue; interface User { id: number; name: string } const list refUser[]([]); const keyword ref(); const filtered computed(() list.value.filter(u u.name.includes(keyword.value)) ); const props defineProps{ title: string; count?: number; }(); /scriptdefineProps用泛型形式声明 props比运行时对象写法简洁得多而且类型直接透传到模板里。refUser[]([])这种带泛型的 ref.value的类型就是精确的User[]不会退化成any。Vue 的编译器宏会把泛型参数转成运行时的 props 定义这一步是 Vue 独有的处理React 里没有对应机制。NestJS 那边前面给的控制器例子已经说明了装饰器和类型的关系。补充一点NestJS 的 DTO 校验依赖class-validator加class-transformer装饰器写在校验类上export class CreateUserDto { IsString() Length(2, 20) name: string; IsInt() Min(0) age: number; }这套东西能生效的前提是emitDecoratorMetadata打开否则反射拿不到类型信息。这个配置项在新版 TS 里跟isolatedModules有冲突风险NestJS 项目里通常不用 esbuild 走tsc或者 SWC 插件来兜。前端接 SpringBoot 接口时我的习惯是把返回结构手写成interface放在单独一个types/api.ts里统一维护。字段一改前端编译立刻报错比联调时对着页面猜要快得多。团队规模再大一点可以用 OpenAPI 的 schema 自动生成 TS 类型省掉手写这一步。4. 常见报错与排查实录4.1 高频报错速查表这张表基本涵盖了我这些年遇到的大头碰到报错先来这儿扫一眼报错信息真实原因处理办法Object is possibly null类型里有null没做判空加if判断或用?.可选链Property xxx does not exist on type yyy访问了类型上不存在的属性检查拼写或补类型声明或做类型收窄Element implicitly has an any type because expression of type string cant be used to index用宽泛string去索引对象用keyof约束或加索引签名Type string is not assignable to type number赋值类型不匹配改类型或做显式转换xxx is declared but its value is never read声明未使用删掉或配置noUnusedLocals: falseCannot find module xxx or its corresponding type declarations缺.d.ts或路径别名没配对装types/xxx或检查paths和打包器别名baseUrl 已弃用将在 TypeScript 7.0 中移除用了旧的路径别名写法删baseUrlpaths改相对路径Cannot use JSX unless the --jsx flag is provided没配 JSX 选项tsconfig里加jsx: preserve或react-jsxA module cannot have multiple default exports重复导出合并导出语句Type instantiation is excessively deep泛型递归太深简化类型或加中间类型打断递归4.2 any 的传染和类型断言的滥用这两个是 TS 项目里最常见的慢性病。any的可怕之处在于它会传染。一个函数的参数是any函数体内的所有操作都不检查返回值也变成any然后传给下一个函数污染范围一路扩散。等哪天你发现某个变量明明有类型却怎么都不报错往回追往往就是一个any的源头。防它的手段有两个一是在tsconfig里开noImplicitAnystrict已经包含二是加 ESLint 规则typescript-eslint/no-explicit-any把显式any也拦下来。真要临时用写unknown更安全——unknown会强制你在使用前做类型收窄。类型断言as的问题不一样它不传染但会直接绕过检查const data await res.json() as User; // 危险 data.name.toUpperCase(); // 编译通过运行可能炸res.json()返回的是anyas User只是骗过编译器实际返回什么它根本不验。稳妥的写法是加一层运行时校验比如用zodimport { z } from zod; const UserSchema z.object({ id: z.number(), name: z.string(), }); const data UserSchema.parse(await res.json()); // data 的类型是真实验证过的 User我一直跟团队说类型断言只用在两种情况——你比编译器知道得更多且非常确定或者在做类型体操的工具代码里。业务代码里的as十有八九是掩盖问题。4.3 面试里最爱问的几个差异题面过不少人TypeScript 相关的问题集中在这么几个顺手把答法说一下。TypeScript 的类型会影响运行时吗不影响。所有类型在编译后都被擦除运行的是纯 JS。唯一的例外是enum和带参数装饰器的元数据它们会产出实际代码。interface 和 type 有什么区别核心是三点interface可声明合并、可被类implementstype能表达联合、交叉、条件、映射等类型运算type不能重复声明。用哪个看场景不是非此即彼。any 和 unknown 有什么区别any放弃检查读写都行unknown保留检查可以赋值给它但用之前必须收窄类型。函数参数类型不确定时用unknown。如何给第三方 JS 库补类型先看有没有types/xxx有就装没有就自己在项目里建types/xxx.d.ts用declare module xxx写最小声明。还可以用declare module *.svg这类通配声明处理静态资源。strict 模式下最难适应的是哪个开关我个人觉得是strictNullChecks因为它会把所有可能为null的地方都翻出来老项目一开往往几百个报错。但它也是收益最大的一个线上Cannot read property of undefined类事故能砍掉一大半。5. 选型建议什么场景用哪个5.1 这些情况用 JavaScript 更省事不是说 TS 一定好有些场景用 JS 确实更舒服一次性脚本、数据清洗小工具总共几十行跑完就扔。为它配tsconfig、装依赖、写类型投入产出比很低。快速验证想法写 demo 试一个库能不能用用 JS 直接开编辑器跑省掉配环境的时间。配置文件和构建脚本很多工具链的配置文件本身就不需要复杂类型vite.config.js完全能打。团队里没人熟悉 TS硬上 TS 的代价可能是所有人都写any那还不如老实写 JS至少不给人虚假的安全感。5.2 这些情况别犹豫直接上 TypeScript多人协作的项目。人一多接口约定就靠不住了类型是最廉价也最有效的契约。生命周期超过半年的项目。写代码的时间和改代码的时间大概是一比三TS 主要在后者的环节省成本。涉及金额、权限、状态机这类业务。这些地方一个字段错就是事故类型检查的收益极高。要发布成 npm 包的库。带.d.ts的库对使用者友好太多现在这基本是标配。配合 Vue 3、NestJS、Angular 这类框架。框架本身就把 TS 当一等公民不用反而别扭。5.3 老项目渐进式迁移的实际路径上来就全量改写是大忌我见过好几个团队这么干最后卡在中途进退两难。可行的做法是分四步第一步改配置不改代码。装上 TypeScript写一份宽松到极致的tsconfigallowJs: true、checkJs: false、strict: false、noImplicitAny: false。让tsc --noEmit能跑通且零报错先建立基线。第二步新文件一律用 .ts。老文件不动新写的业务全部走 TS。这一步持续一两个月新文件占比能到三成以上。第三步逐个模块升级。按业务模块而不是按目录树改一个模块改完自测通过再进下一个。改的时候优先处理数据模型层——把接口的响应结构定义成interface收益最大。第四步收紧配置。开noImplicitAny把暴露出来的any一个个消掉再开strictNullChecks处理空值最后开strict。每次收紧都单独提一个 PR别跟业务需求混在一起出了事好回滚。一个实用的小技巧迁移期可以用// ts-expect-error精确豁免某一行而不是// ts-ignore。区别在于前者在错误消失后会反过来报错提醒你删掉这行注释后者会一直留着变成技术债。这个小细节能让豁免清单自动收敛。我在几个项目里反复验证过一件事TypeScript 带来的最大收益从来不是类型写起来很爽而是当你半年后回来改一段陌生代码时编辑器能告诉你这个函数要什么、返回什么、哪个分支没处理。这个收益在小项目里感受不到项目一大就非常明显。至于那些配置太麻烦报错太多的抱怨多数来自迁移方式和配置没调对而不是 TS 本身的问题。踩过几次坑之后我的经验是前期多花半天把tsconfig和 CI 卡口配明白后面能省掉几十个小时的排查时间这笔账很划算。