JS 到 TS 实战:类型安全、编译期约束与项目迁移
凌晨一点的告警群是全公司最诚实的角落。运维贴过来一段堆栈TypeError: Cannot read properties of undefined (reading nickname)。我打开对应页面参数明明传了 nickname……最后定位到后端某条边界分支返回了空对象。这种 JavaScript 运行时报错凡写过几年前端的人都不会陌生动态类型给了你“怎么传都行”的自由代价是所有错误都延迟到用户点击的瞬间才爆炸。TypeScript 的出现核心意义不是给你加一堆看起来很专业的标注而是把这些错误提前挪到编译期。它是 JavaScript 的超集在原语法基础上叠加了一套类型安全约束。如果你写 JS 已经有些年头、开始觉得重构和排查越来越累或者面试时被 TS 各种写法卡住那这篇按我个人实务经验整理的入门路线应该对你有用。下文从“为什么需要类型安全”讲起一路聊到环境搭建、核心语法、老项目迁移和排错思路每一步都有能直接上手的细节。1. 为什么从 JavaScript 出发的人最该先搞懂“类型安全”是什么1.1 JavaScript 的动态类型自由和代价是同一枚硬币JavaScript 能火起来靠的就是动态类型。函数参数不声明类型、对象随时可以加属性、变量想装字符串就装字符串这种灵活性让脚本语言在小规模场景里几乎零门槛。但项目一大这份自由就开始反噬。举个例子一个很常见的购物车计价函数function calcTotal(cart) { return cart.items .map((item) item.price * item.quantity) .reduce((sum, n) sum n, 0); }这段代码在“正常调用”时没有任何问题calcTotal({ items: [{ price: 20, quantity: 2 }] })返回 40。但线上出 bug 的从来不是正常路径。可能是calcTotal(undefined)也可能是接口某个字段没返回导致cart是个空壳。JS 不会在函数入口拦你真正的报错会发生在.map被调用的一瞬间而且报错位置在库代码深处堆栈经常指向第三方包而不是你的业务代码。于是你只能一遍遍打console.log或者临时塞断点在一个几十层调用链里人肉定位。同一个函数换成 TypeScript 来写差别就非常明显interface CartItem { price: number; quantity: number; } interface Cart { items: CartItem[]; } function calcTotal(cart: Cart): number { return cart.items.reduce((sum, item) sum item.price * item.quantity, 0); }如果你在别处写calcTotal(undefined)不必等发布编辑器里直接就是红色波浪线编译阶段直接报错。这就是“类型安全”最直观的含义在代码还没运行的时候先用一套静态规则把不合理的调用挡住。1.2 类型安全是编译期约束不是运行期魔法这里有一个新手最容易混淆的认知以为 TypeScript 会在运行时帮你检查数据。其实不会。tsc编译产物是标准 JavaScript类型信息在编译过程中会被完全擦掉。也就是说浏览器里跑的、Node 里执行的、嵌入式引擎里加载的从来都不是 TypeScript而是编译后的 JS。类型安全的意思是你的代码在编译阶段被证明“在类型层面一致”。比如函数声明参数是Cart那所有调用它的地方传入的值在类型上必须符合Cart。但这不保证真实运行时数据一定符合——因为外部接口返回的数据、用户输入的数据、本地存储里可能残留的老版本字段TypeScript 都看不见。你要做运行时校验还是得靠if判断或者zod之类的库。这是个很关键的认知边界编译不报错不等于运行没风险但编译能报错至少帮你挡掉了相当大一类低级错误。我在实际项目里的体感是类型安全解决的是编码环节里的“手感”问题把精力从查错中解放出来而不是替代测试和运行时防御。1.3 TypeScript 是超集不是替代品可以渐进进场很多人对 TS 有抵触潜意识里把它当成一门“新语言”担心要重写全部代码。实际上 TypeScript 是 JavaScript 的超集合法的 JS 大体上就是合法的 TS。你可以把一个.js文件直接改名成.ts再一点点加上类型标注编译器会一边报错一边允许你渐进修复。这个设计是整个 TS 生态最值得称赞的地方。它不像某些框架那样要求你“推翻重来”也不要求在项目启动第一天就全量迁移。你完全可以从一个模块、一个文件、甚至一个函数开始引入类型。后面我会专门讲渐进迁移路线这里想先强调一个心态别把 TS 当成项目改造的 KPI把它当成一把趁手的工具从最痛的代码开始用。2. 环境搭建与 tsconfig 配置核心选项决定你未来半年的体验2.1 三步跑通最小可用环境我见过太多人卡在环境搭建上其实 TS 的搭建流程短得惊人。在项目根目录执行npm init -y npm install --save-dev typescript npx tsc --init第一条是初始化 npm没有 package.json 的项目先创建第二条把 TypeScript 装成项目局部依赖。这里我不建议全局安装typescript原因很现实如果一个项目用 TS 5.x另一个项目还用 4.x全局装一个版本会让不同项目的编译结果不一致CI 环境也会踩版本坑。局部依赖能在 package.json 里锁住版本团队成员npm install后天然对齐。npx tsc --init会在当前目录生成一份默认的tsconfig.json。把它当作起点按下一节说的重点项去调整就够了。最后加一行编译命令{ scripts: { build: tsc } }然后npm run buildTS 就会按配置编译入口文件。当然实际工程里你大概率不会直接裸用tsc做构建而是把它交给 Vite、webpack、esbuild 这些构建工具处理但理解裸tsc的行为是看懂各种工具链配置的基础。2.2 tsconfig 选项取舍表直接照着抄tsc --init生成的配置项有几十个容易劝退。下面这份表格是我在多个项目里沉淀下来的常用基线你可以照着改每一项我只留一句话说清原因。配置项推荐值原因targetES2020或按部署环境调整决定编译输出哪种 JS 语法老浏览器环境需降级moduleESNext或CommonJS配合打包器或 Node 运行时的模块规范stricttrue这是 TS 能保护你的核心开关别关strictNullCheckstrue让null/undefined不再随处可传收益最大的一项moduleResolutionbundler或node影响 import 路径解析方式打包器项目选 bundleresModuleInteroptrue解决 CommonJS 和 ES Module 互操作的别扭问题skipLibChecktrue第三方.d.ts经常互相冲突跳过声明文件检查sourceMaptrue浏览器调试时能映射回 TS 源码不然断点全在编译产物上outDirdist编译产物统一输出避免和源码混在一起rootDirsrc源码目录固定住编译输出结构才干净重点说strict。它是一组严格检查的合集默认开启后像隐式any、空值检查、函数参数严格匹配等都会打开。有人为了“先跑起来”把strict关掉我的看法是如果你打算长期使用 TSstrict从一开始开就是对的。关闭 strict 的 TS 项目类型标注基本形同虚设两年后回来再看一堆any和潜在空值问题堆成山那时候再开 strict 的迁移成本远高于第一天开。宁可前面几天被报错烦一点也不要在后面欠技术债。2.3 类型擦除的直观理解浏览器里只有 JavaScript为了搞懂 TS 到底做了什么我建议你亲手编译一个最小文件看看。假设有一个src/index.tsinterface User { id: number; name: string; } let current: User { id: 1, name: Tom }; function greeting(person: User): string { return Hello, ${person.name}; }配置好tsconfig.json后执行npm run build打开dist/index.js你会看到let current { id: 1, name: Tom }; function greeting(person) { return Hello, ${person.name}; }接口User完全消失参数类型标注被剥离字符串模板保留原样。这就是“类型擦除”。TS 的世界里类型信息只存在于编译前运行时无迹可寻。理解这一点很多问题就通了。比如之前看到有人搜“QuickJS 支持 TypeScript 吗”答案很简单QuickJS 是一个嵌入式 JavaScript 引擎它解析和执行的是标准 JS 语法本身根本不认识 TS 的类型注解。想在 QuickJS 里“用 TypeScript”正确方式是先用tsc或esbuild把.ts编译成.js再把 JS 交给 QuickJS 加载。这个流程不是兼容性缺陷而是 TS 的架构边界——它不改变运行时只约束开发期。后面讲老项目迁移时这条认知会反复用到。3. 从基础标注到泛型工具类型一份能直接抄的 TS 语法清单3.1 对象、函数、数组的基本标注先写成契约很多人学 TS 死在“语法太多不知道先学哪些”。我的建议是先把高频场景拿下对象、函数、数组、可选属性。一份日常够用的骨架长这样type User { id: number; name: string; email?: string; // 可选属性可能没有 role: admin | user; // 字面量联合限定取值 }; function formatUser(user: User): string { return ${user.name} (${user.email ?? no email}); }这里有两个细节值得展开。第一email?表示这个属性可以不存在TS 将其类型理解为string | undefined。访问它时最好用??或if收窄否则在strictNullChecks开启的情况下直接把user.email当字符串用会报错。第二admin | user是字面量联合类型比单纯写string好得多——它能让你在写判断时得到自动补全还能在拼错单词时立刻报错。函数的返回值标注: string也值得养成习惯。它像一份微型文档告诉调用者这个函数返回什么。项目里最烦的代码就是那种“返回值全靠猜”的函数而显式标注能让整个调用链都清爽起来。3.2 interface 与 type面试必问的那个选择interface和type都能描述对象结构很多新手搞不清区别。我的选择法则是描述对象契约优先用 interface描述联合类型、交叉类型、元组等结构时用 type。为什么因为 interface 有两个 type 给不了的特性。一是声明合并interface Window { __MY_FLAG__?: string; } // 另一个文件里还能继续扩 interface Window { __OTHER_FLAG__?: number; }这在扩展全局对象、给第三方类型补充字段时很常用。二是 interface 可以被extends继承也能被implements实现面向对象风格的代码更顺手。但 type 更灵活。你可以写type Status success | error也可以写type PairT [T, T]这些联合、元组、映射类型interface 表达不了。所以大多数场景两者都能用凭一条规则选择就够了要表达“这是个对象的形状”就选 interface要表达“这是个类型运算”就选 type。3.3 联合类型与类型收窄处理不确定状态的核心姿势业务开发里最多的是什么不是复杂算法而是“数据有多种状态”。请求可能成功也可能失败用户可能登录也可能未登录表单可能填完整也可能缺字段。TS 处理这类不确定性最顺手的方式就是判别联合。type ApiResponseT | { status: success; data: T } | { status: error; code: number; message: string }; function handleResponseT(res: ApiResponseT) { if (res.status success) { console.log(res.data); // 这里 data 安全可用 } else { console.error(res.code, res.message); // 这里只有 error 分支的字段 } }关键点在判别字段status。TS 会按字面量类型进行收窄当res.status success成立时整个res在后续代码里被推断为成功分支的结构反之则被推断为失败分支。这就是“类型收窄narrowing”。比instanceof和typeof更常用的是这种 “以一个确定的字段判断整个对象” 的写法。后端接口、前端状态机、消息队列事件都能套这个模式。我第一次真正喜欢上 TS就是发现这段代码再也不用写一堆if (res res.data)的防御逻辑了——类型系统替我证明了分支安全。3.4 泛型不是高深理论是消除重复类型代码的工具泛型这个词听着吓人本质就是“让类型像参数一样可复用”。最常见的一个场景是封装请求函数async function requestT(url: string): PromiseT { const res await fetch(url); const json await res.json(); return json as T; } const user await requestUser(/api/user);这里T是一个待填充的类型占位符。调用时传User返回类型就是PromiseUser下次调requestArticle函数实现不用改返回类型自动变成PromiseArticle。没有泛型的话要么写多个重复函数要么返回any让类型安全白搭。泛型还可以加约束用extends限定传入类型必须满足某种形状function identityT extends { id: number }(item: T): T { return item; }这样identity({ id: 1 })合法identity(hello)会直接报错。面试题里经常考这个实际项目里其实也很用得上——尤其是当你需要封装一个“既能处理多种类型又要求这些类型必须具备某个公共字段”的工具函数时。3.5 any、unknown、never严格模式下的三个岔路口这三个类型非常容易混淆我用一张表说说区别类型行为使用场景any关掉该位置的类型检查类型系统自动闭嘴渐进迁移的临时过渡建议留 TODO 并尽快替换unknown表示“存在一个值但类型未知”访问前必须收窄外部数据边界如接口返回、用户输入never表示“永远不可能出现的值”常用于穷尽检查switch 所有分支处理完后的 default 兜底any最危险的地方是会传染。一个函数返回any所有调用它的地方都会失去类型推导错误会被推得很远。替代写法是unknown加手工收窄const raw: unknown await response.json(); // 先收窄成对象再访问属性 if (typeof raw object raw ! null data in raw) { console.log((raw as { data: string }).data); }never的典型应用是穷尽检查。处理完所有联合类型的成员后在 default 分支里断定“不可能走到这里”如果哪天有人给联合类型加了新成员编译器会在穷尽检查处报错。这个技巧等你项目大了之后会非常实用。4. 老项目渐进迁移路线不重写代码也能吃到类型安全红利4.1 从 JSDoc 和 allowJs 开始的第一步老项目迁移最大的阻力从来不是技术而是业务代码量。所以第一步我推荐的成本最低给现有 JS 文件开启轻量类型检查而不需要把文件改成.ts。先给tsconfig.json加上两条{ compilerOptions: { allowJs: true, checkJs: true } }allowJs允许 TS 处理.js文件checkJs则让编译器对 JS 文件做类型推断和检查。这时你可以在个别文件顶部加一行// ts-check单独开启检查而不是全局一刀切。配合 JSDoc 注释可以给 JS 代码标注类型// ts-check /** param { { items: { price: number; quantity: number }[] } } cart */ function calcTotal(cart) { return cart.items.reduce((sum, item) sum item.price * item.quantity, 0); }这一步不改变任何运行逻辑纯粹让编辑器开始“看到”问题。我在一个老项目上测试过光是把 JSDoc 标到核心接口层日常开发时 IDE 的补全和错误提示就明显变好了。很多人不知道这一步所以最后被迫全量迁移其实渐进路线从注释开始就能尝到甜头。4.2 以“数据入口”为优先的迁移顺序如果你决定正式迁移顺序比速度重要。我的建议是不要从工具函数开始迁移而要从数据入口开始。数据入口就是那些“外部数据进入项目内部的代码点”主要是接口请求层和本地存储读写。先把这些地方改成 TS 并定义好类型全项目就能在编辑器里看到数据结构的补全和报错。一个典型的接口封装迁移示例interface ApiResultT { code: number; data: T; message: string; } export async function getT(url: string): PromiseT { const res await fetch(url); const result (await res.json()) as ApiResultT; if (result.code ! 0) { throw new Error(result.message); } return result.data; }下游所有get(/api/user)的调用点返回值类型都是明确的User。改完接口层再逐步迁移状态管理和页面组件迁移一个文件就验证一个文件。那些无人维护、很少改动的历史文件完全可以留在.js状态没必要一次性全部动刀。4.3 迁移中常见的三个坑迁移过程里最常见的坑有三个每一个我都踩过。第一个是“遇到报错就 any”。有人觉得any是万能钥匙但any会让类型检查在该处断裂错误会漂移到更远的位置排查成本反而更高。更稳妥的替代方案是暂时换成unknown在真正使用的位置做一次收窄或者直接定义一个最小的interface只写上当前用到的字段。第二个是“第三方库没有类型声明”。很多老的 npm 包的确没有自带类型也不一定有社区维护的types/xxx。这时可以创建一个.d.ts文件手动声明declare module old-lib-name { export function doSomething(input: string): number; }只需要声明你用到的部分编译器就不会报模块找不到。日常里像 FullCalendar 这类功能复杂的库往往自带类型或由官方维护types集成时优先看文档里的类型说明能省不少事。第三个是“JS/TS 混编状态下构建链混乱”。如果一个文件是 JS、一个文件是 TS且构建工具只配置了.js的 loaderTS 文件就很尴尬。建议不管用什么工具链先保证项目里所有被加载的 TS 文件都走统一处理Vite 天然支持、webpack 配置ts-loader、纯 Node 项目用tsx运行。最忌讳的是“一半走 TS 编译一半靠 Babel 硬转”最后两个处理链路各有一套理解排查起来非常痛苦。4.4 运行时没有 TSQuickJS、WebView、嵌入式脚本场景的真相之前聊过类型擦除这里展开说说它在特殊运行环境里的影响。有人会担心“我用了 TS还能在 iOS 的 WebView 里跟 OC 互相调用吗”“QuickJS 这么轻量的 JS 引擎能不能直接跑 TS”“Kettle 里内嵌的 JS 脚本是不是不能接 TS”。答案其实都一样运行时根本不认识 TypeScript但编译后的标准 JS 哪个环境都认识。拿 QuickJS 举例它是一个嵌入式 JS 引擎体积小、启动快常被用在物联网、游戏脚本、插件系统里。它只执行标准 JavaScript不包含任何 TS 的类型解析逻辑自然“不支持 TypeScript”。但只要你在开发环境中用tsc把.ts编译成.jsQuickJS 就能正常加载执行类型安全问题完全在编译阶段解决。类似地OC 和 JavaScript 之间的桥接、WebView 里注入的脚本、Kettle 里的 JavaScript 步骤它们看到的永远是 JS。TS 对它们没有任何运行时侵入反而因为编译期多了一层检查你写在桥接层的数据会少很多“类型对不上”的运行时异常。所以不要纠结“某个环境支持 TS 吗”而要问“编译后的 JS 能在那里跑吗”后者几乎总是 yes。5. 类型报错排查与面试高频题把类型安全用到日常工作中5.1 类型报错排查思路把报错当线索链用 TS 到一定阶段你会有大量时间面对类型报错。很多人一见红色波浪线就慌其实报错本身就是最好的线索链。我通常按这几步排查先看报错提示里的类型名。比如Property data does not exist on type ApiResponseunknown它直接告诉你当前变量被推断成了ApiResponseunknown而不是你期望的成功分支类型。这时候要想到“我没有做类型收窄”回到代码检查在访问data之前是否已经用status判断过了。再看函数签名。在 IDE 里悬浮函数名能看到完整的参数类型和返回类型往往瞬间就能发现是参数类型不符还是返回值类型被错误推导。用ReturnTypetypeof fn这类工具类型也能快速从函数实现里“提取”出它返回的类型。如果报错在很深的嵌套里一个实用的技巧是把大类型拆开。报错信息里出现一长串联合类型时可以把其中一部分抽出来单独写成type A ...在代码块里逐步测试缩小范围。绝大多数 TS 报错都不是“这代码跑不了”而是“你在代码里承诺的类型和实际不一致”顺着线索找很快能定位到承诺出错的位置。5.2 条件类型与 infer解包 Promise 的实战写法日常写业务最常在函数返回值上看到PromiseSomeType。如果你想在类型层面把SomeType从 Promise 里“拆”出来就需要条件类型和infer。type UnwrapT T extends Promiseinfer U ? U : T; type A UnwrapPromisestring; // string type B Unwrapnumber; // numberinfer U的意思是让 TypeScript 自己去推断这个位置是什么类型并把它命名为U。如果T匹配Promise...的形状就取出内部类型不匹配就返回T本身。这个写法很基础但很有用它也是 TypeScript 内置工具类型Awaited的简化原型——TS 4.5 之后官方直接提供了递归解包的AwaitedT。配合ReturnType使用能写出非常地道的类型工具async function fetchUser() { return { id: 1, name: Tom }; } type User UnwrapReturnTypetypeof fetchUser; // User { id: number; name: string }不用手动重复声明 User 结构直接从函数实现里推导出来。类比的思路还能用在ParametersT取函数参数类型、InstanceTypeT取类实例类型上。这类“把类型当数据操作”的能力是 TS 区别于其他类型系统的重要特征也是面试里高频的加分项。5.3 几个容易被问倒的 TS 面试题速答面试里 TS 的题目翻来覆去就那么几个但很多人因为没系统梳理过临场容易说得含糊。挑几个我常被问的类型用一行答案式的方式说清楚。interface和type怎么选对象描述优先 interface它支持声明合并和 extends需要联合、元组、映射等类型运算时用 type。as const是干什么的让字面量在推导时保持最窄类型。比如const arr [1, a] as const;arr的类型就是readonly [1, a]而不是(number | string)[]。写枚举式配置对象时特别有用。satisfies是干什么的TS 4.9在“保持精确推导”的同时“检查是否符合某个类型”。比如const config { theme: dark } satisfies Recordstring, string;config.theme的类型是dark而不是string但又通过了对整体结构的类型检查。为什么any不推荐any会让类型检查在该处短路错误被推到更远的地方而且它有传染性。严格模式下的替代是unknown 收窄。keyof、in、索引访问类型怎么配合keyof T取所有键名[K in keyof T]遍历键T[K]取对应属性类型。这三个组合起来就是映射类型的基础内置的PartialT、PickT, K基本都是这套思路实现的。never什么时候出现函数永不返回时如抛异常、联合类型剔除所有成员后、infer无法匹配时。它代表“不可能”在穷尽检查里是安全网的最后一环。最后再分享一点实际体会文章写到这里核心内容都说完了。最后说点掏心窝子的经验。我自己最开始学 TS 时也很不耐烦觉得类型标注是“额外的仪式感”不如直接写 JS 痛快。直到在一个维护了两年、十几个前端模块的项目里反复经历“改一个字段类型全局找谁在用”“加一个可选参数某处忘传线上才炸”之后才真正意识到类型安全的价值不在当下而在漫长的维护期里。它像一份能自动更新的接口文档每时每刻告诉你“这里会收到什么、那里会返回什么”。我的建议很简单从今天起在你最常改动的那个数据文件上面加一层类型定义。不用多一个接口、一个函数就够。当你习惯了 IDE 自动补全和编译期报错带来的踏实感你会回来感谢现在认真读这篇入门的自己。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →