TypeScript与JavaScript边界:类型擦除与any/unknown
接私活的时候最怕看到什么打开项目目录根目录躺着一个tsconfig.json点进去一看strict: false再往下翻src里.js和.ts混着放同一个文件夹里a.js引b.tsb.ts又反过来 import 回去any像补丁一样贴得到处都是。这种项目我一共接手过三个每次第一件事都不是改代码而是先把TypeScript和JavaScript的边界在脑子里重新理清楚——哪些问题是JavaScript本身的性质决定的哪些问题是TypeScript能提前拦住的哪些问题是TypeScript根本管不了、只能靠运行时兜底的。这三类问题分不清改到最后就是给any换个位置白干。还有个场景更常见。前阵子一个朋友去面试被问到TypeScript 和 JavaScript 的区别他张嘴就是TS 是 JS 的超集TS 有静态类型TS 需要编译成 JS 才能跑面试官点点头接着追问了一句那你写个any和一个unknown编译完产物里有区别吗人就卡住了。这三句话不算错但它们是简历上的措辞不是干活的人脑子里装的东西。真正决定你代码质量的是下面这些更具体的问题类型信息在编译后到底去了哪、interface为什么能跨文件自动合并、declare global什么时候必须用、Object.assign合并对象时类型推断为什么会失真、querySelector返回null时 TS 逼你做了什么、tsconfig里那个被标记弃用的baseUrl到底动过谁的蛋糕。这篇就按这个思路来从几个真实会撞上的代码片段出发把TypeScript和JavaScript的分界线一段一段划清楚顺带把面试里那些追问的答题思路也拆开讲。不管你是刚开始学typescript教程的新手还是已经能用typescript nestjs搭服务、但说不清类型擦除的老手都值得往下看。1. 一个document.querySelector的报错暴露了 JS 最根本的性格1.1 浏览器不报错的代码不代表它是对的先看一段几乎所有人都写过的代码。你想把一个视频元素转 90 度JavaScript里顺手就是两行const v document.querySelector(video); v.style.rotate -90deg;这段代码在页面里大概率能跑于是很多人就认为它没问题。但它有两个隐患JavaScript一个都不会提醒你。第一如果页面上压根没有video标签querySelector返回的是null那么第二行会直接抛TypeError: Cannot read properties of null (reading style)整个脚本从这里断掉后面所有逻辑全部不执行。这个错误不会在你写代码的时候出现它只会在某个特定页面、特定时刻出现然后被用户第一个发现。第二style.rotate这个属性在旧版浏览器的CSSStyleDeclaration上并不存在。JavaScript对此的处理方式是不存在的属性读出来是undefined写进去就是往这个对象上挂一个自定义属性浏览器该不转还是不转代码该往下跑还是往下跑没有任何反馈。同样的两行换成TypeScript编辑器里立刻会有两条红线。第一条在v.style上提示v is possibly null第二条在rotate上提示Property rotate does not exist on type CSSStyleDeclaration。有意思的是第二条——这其实是一个反向例子早期lib.dom.d.ts里确实没有收录rotateTypeScript报错了但浏览器是支持的。这时候你才需要判断到底是我的写法有问题还是类型定义落后了。这个判断过程本身就是收获JavaScript连让你做判断的机会都不给。这就引出了JavaScript最根本的性格它是动态类型语言类型检查发生在运行时而且检查得非常宽松。宽松到往对象上挂一个它不认识的属性这种事它认为是完全正常的操作。1.2 动态类型的真正代价错误离现场太远很多人对动态类型的理解停在写起来快但真正的代价不在这儿而在错误发生的位置和根因之间的距离。举个例子。后端接口返回的字段叫userName你手滑在某个工具函数里写成了username。这段数据从fetch拿到传给normalize再传给store再传给组件最后在渲染用户名的地方变成一片空白。你在页面上看到的症状是用户名没显示但真正的错误在两百行以外的那个字符串字面量里。JavaScript的控制台会告诉你某某属性是 undefined也就是说堆栈给你的是案发现场不是作案动机。你得自己沿着数据流一路回溯才能找到那个拼错的字段。同类的还有几种高频情况函数参数少传一个JavaScript不报错那个参数就是undefined然后在函数体深处炸掉对象上访问一个不存在的键得到undefined传给下一个函数继续往下传直到某个地方做了.toFixed()或者.length才炸数组越界取到undefined参与运算后得到NaN一路污染到最终结果。TypeScript干的事情说白了就是把一部分这类检查从运行时搬到编译时。注意是一部分这一点特别重要后面还会展开讲。1.3 类型信息在编译后去了哪里这是面试最容易翻车的地方也是理解两者关系的关键。答案很干脆类型信息在编译产物里一点不剩全部被擦除了。// 源码 function add(a: number, b: number): number { return a b; } const list: string[] [a, b];编译成JavaScript之后是这样function add(a, b) { return a b; } const list [a, b];a: number、b: number、返回类型number、string[]全都没了。产物里只剩下JavaScript本身这也是为什么TypeScript编译出来的东西可以直接丢给任何浏览器、任何运行时——它最终就是一个普通的.js文件。明白了这一点很多困惑就自动解开了。为什么TypeScript不能在运行时校验接口返回的数据因为编译完类型就没了运行时手里根本没有这个字段应该是 string这条信息。所以你在NestJS里拿到的DTO类型它保护的是你写代码时不出错不是收到脏数据时能自动挡住。真要做运行时校验得靠class-validator、zod、io-ts这类工具它们把类型信息用别的方式装饰器元数据、schema 对象在运行时保留了一份。这是两套完全不同的机制别混为一谈。还有enum。常规enum编译后会生成一个真实的对象所以它在运行时是存在的const enum则会在编译时被内联替换掉运行时不留痕迹。这中间的差别在isolatedModules打开之后会变成一个真实的坑后面第 4 节会细说。对比维度JavaScriptTypeScript类型检查时机运行时编译时编辑器 构建类型错误的表现抛异常或静默产生undefined/NaN编辑器标红、CI 直接失败类型信息是否保留到运行时不存在这个概念编译后完全擦除声明文件无.d.ts只描述不实现重构时的信心来源测试覆盖率类型检查 测试上手门槛低写就能跑中要理解类型系统2. 结构类型、any与declare global三个最容易被理解错的点2.1 TypeScript 不看名字只看形状TypeScript用的是结构类型系统structural typing业内俗称鸭子类型只要一个值的形状满足要求它就是合格的跟你叫什么名字没关系。这跟 Java、C# 那套名义类型nominal typing完全不同也是很多从后端转前端的人最不适应的地方。interface Point { x: number; y: number; } interface Vector { x: number; y: number; } const p: Point { x: 1, y: 2 }; const v: Vector p; // 不报错形状一样在 Java 里这行一定编译不过因为Point不是Vector的子类。在TypeScript里它完全合法。理解这一点之后你会发现很多奇怪的现象都有了解释为什么一个类不需要 implements 某个接口也能被当作该接口使用为什么函数参数只要形状兼容就行多出来的属性在某些场景下反而会报错对象字面量的多余属性检查是特例。这个特性带来的最大好处是类型可以随手组合不用为了传递数据去建立复杂的继承树。坏处是容易撞型——两个语义完全不同的东西因为字段一样被当成了同一个类型这时候需要靠品牌类型branded type这种技巧打上标记type UserId string { readonly __brand: UserId }; type OrderId string { readonly __brand: OrderId };这样两者形状上都是string但因为多了个独有的标记互相赋值就会报错能有效防止把订单 ID 当用户 ID 传。2.2any、unknown、never的边界感这三个是TypeScript特殊类型里最常被问到、也最常被用错的。any的作用是彻底关掉检查。一个值变成any之后你可以在它身上做任何操作访问任何属性调用任何方法TypeScript全都不管。所以any会像病毒一样扩散——一个any类型的变量参与运算结果还是any传进函数再传出来还是any。老项目里any一多等于开了TypeScript但还是写JavaScript。unknown是安全的any。它同样表示不知道是什么类型但你不做类型收窄就不能用它——不能读属性、不能当函数调用、不能参与运算。想用就得先收窄function handle(input: unknown) { if (typeof input string) { return input.toUpperCase(); // 这里 input 已被收窄为 string } if (Array.isArray(input)) { return input.length; } return null; }处理外部数据接口响应、JSON.parse的结果、postMessage收到的消息时unknown是比any正确得多的选择。它的强制收窄机制会逼着你把边界情况写清楚。never表示不可能存在的值。它最常见的用法是做穷尽检查type Shape circle | rect | line; function area(s: Shape) { switch (s) { case circle: return 1; case rect: return 2; case line: return 3; default: { const _exhaustive: never s; // 如果漏了某种情况这里会报错 return _exhaustive; } } }以后Shape加了新成员忘记处理时编译器立刻报错这个技巧在维护长期项目时价值极高。2.3declare global和命名空间给没有类型的老代码补壳真实项目里迟早会遇到这种情况用了某个第三方库它把东西挂在了window上或者你在 script 标签里引了一个老工具函数TypeScript完全不认识它。这时候就得手动补类型声明。先记住一条容易踩的规则在.d.ts文件里一旦出现了顶层的import或export这个文件就变成了模块里面直接写的interface就不再是全局的。想声明全局类型必须用declare global包起来// types/global.d.ts export {}; // 这一行让文件成为模块 declare global { interface Window { __APP_CONFIG__: { apiBase: string; version: string; }; } var __TRACK__: (event: string, payload?: Recordstring, unknown) void; }注意var那行——声明全局变量要用var用let或const会报错因为let/const不会挂到全局对象上。这个细节卡过很多人明明写对了却一直报 Cannot redeclare block-scoped variable。至于namespace它算是历史遗留方案。早期的TypeScript用它做大模块拆分内部模块后来有了 ES Module 之后业务代码里基本不该再用namespace了。现在它还有存在价值的场景只有两类一是给老式的全局库写声明文件二是写一些需要和枚举混合使用的工具类型。新项目里看到业务代码用namespace组织模块基本可以判定是照着老教程写的。2.4[{}]这类写法的误会搜索里有typescript [{}]这种词挺能说明问题。不少人看到interface Point {...}或者type List [{}]会懵。先说清楚几个容易混的写法。interface用的是花括号直接跟不带等号interface User { id: number; }type用等号type User { id: number };而[{}]表示的是一个数组里面装的对象的形状是空的。这里的{}不是空对象的意思——这是另一个大坑。在TypeScript里{}类型的含义是除null和undefined之外的任何值。数字、字符串、数组、函数全都能赋值给{}。想表达空对象得用Recordstring, never或者干脆定义一个明确的接口。理解错这一点写出来的类型守卫就会形同虚设。3. 写业务时真正分叉的地方合并对象、动态调用、原型链3.1 合并两个对象深浅之间埋着两层坑javascript合并两个对象这个需求高频得离谱写法大致就那几个但每个都有自己的脾气。第一个是Object.assignconst target { a: 1, b: 2 }; const source { b: 3, c: 4 }; const result Object.assign(target, source); console.log(target); // { a: 1, b: 3, c: 4 } target 被改了 console.log(result target); // true坑就在这儿Object.assign会修改第一个参数并且返回的就是这个被改过的对象本身。想不污染原对象第一个参数必须传空对象Object.assign({}, a, b)。第二个是展开运算符写法更干净const merged { ...a, ...b };它同样是浅拷贝。这就引出第二层坑如果a和b里都有嵌套对象合并后两个位置指向的可能是同一个引用改一个另一个也跟着变。const a { user: { name: A } }; const b { user: { name: B } }; const m { ...a, ...b }; m.user.name C; console.log(b.user.name); // Cb 被连带改了真需要深拷贝JSON.parse(JSON.stringify(obj))是流传最广的土办法但它有几个硬伤函数、undefined、Symbol会被丢掉Date会变成字符串Map、Set、RegExp直接失真遇到循环引用会抛异常。现在更推荐structuredClone它处理循环引用和多数内置类型都没问题但同样不能克隆函数和 DOM 节点。再从类型角度看这件事。用展开运算符合并TypeScript推出来的类型基本是对的用Object.assign(a, b)返回类型是A B的交叉类型——如果a和b有同名字段但类型不同交叉之后会得到never然后你会发现这个字段完全用不了报错信息还挺绕。这种情况建议显式标注结果类型别让编译器自己猜。3.2 用字符串调用函数JS 放行、TS 拦下javascript 通过字符串调用函数也是热搜常客。JavaScript里这么写const actions { save() {}, cancel() {} }; const key save; actions[key](); // 能跑 actions[key2](); // key2 拼错了运行时报 actions[key2] is not a functionTypeScript里actions[save]会报Element implicitly has an any type because expression of type string cant be used to indexnoImplicitAny打开时。想让索引合法得给对象加索引签名const actions: Recordstring, () void { save() {}, cancel() {} };但加了索引签名之后检查就基本放弃了——任何字符串都能索引取到undefined也照样放行。更好的做法是用keyof收窄const actions { save() {}, cancel() {} }; type ActionKey keyof typeof actions; function run(key: ActionKey) { actions[key](); } run(save); // 合法 run(sav); // 编译期直接报错这样做的好处是keyof typeof会随着对象成员自动更新加一个方法就多一个合法键改一个方法名就会在使用处报错改错别字这件事从此在编译期就被挡住了。这里必须加一句安全提醒永远不要用外部传入的字符串直接拼函数名去调用。无论是从 URL 参数、消息内容还是接口响应里拿到的字符串直接当函数名去索引一个对象并执行等于给了对方一个执行任意已注册函数的口子风险没法估量。稳妥的做法是维护一张显式的白名单映射表找不到就拒绝。3.3 没new完的对象为什么能用prototype这个话题搜索里也出现过而且很多人第一次看到会愣住。其实这个问法本身就带着误解——prototype不是new完之后才有的东西。prototype是函数对象自带的一个属性函数一被创建它就在了跟new完全无关function Person(name) { this.name name; } console.log(typeof Person.prototype); // object此时还没 new 过new做的事情是创建一个新对象、把新对象的原型链指向Person.prototype、把这个新对象当this执行Person、返回它。所以没 new 完的对象能用 prototype这个说法的准确表述应该是函数上一直有prototype实例上通过__proto__也就是Object.getPrototypeOf指过去。function Person(name) { this.name name; } Person.prototype.say function () { return this.name; }; const p new Person(Tom); console.log(Object.getPrototypeOf(p) Person.prototype); // trueTypeScript跟这套机制是什么关系class语法在类型层面是名义类型的private、protected成员让两个形状相同的类互不兼容但在编译成低版本目标比如ES5之后产物就是上面这套函数加prototype的写法。也就是说TypeScript给你的是写代码时的名义类型体验运行时跑的仍然是原型链那一套。分清这两层面试问TS 编译后 class 变成什么就能答到点上。3.4 事件监听和 DOM 的类型收窄javascript监听、javascript中表单提交和h5的区别这类需求最后都落到 DOM 操作上而 DOM 操作是TypeScript最能体现价值的地方之一因为它把每个可能为null的地方都标了出来。document.querySelector(video)的返回类型是HTMLVideoElement | null。想直接用就得先判空const v document.querySelector(video); if (v) { v.muted true; }或者用泛型指定选择器对应的元素类型省去手动断言const form document.querySelectorHTMLFormElement(#login); form?.addEventListener(submit, (e) { e.preventDefault(); const data new FormData(form); console.log(data.get(username)); });addEventListener的第二个参数里e的类型会根据事件名自动推断submit对应SubmitEventclick对应MouseEvent不用自己写断言。这个推断在重构时特别香——把事件名从click改成keydown回调里访问e.key立刻就有类型支持。还有一条容易被忽略的removeEventListener必须传入同一个函数引用。写成el.addEventListener(click, () {...})再用同样的箭头函数去 remove是永远删不掉的因为那是两个不同的函数对象。TypeScript在这件事上帮不了你它只能保证参数类型对得上。这也是为什么会推荐把回调抽成具名函数function onClick(e: MouseEvent) { /* ... */ } el.addEventListener(click, onClick); el.removeEventListener(click, onClick);4.baseUrl被弃用这件事其实是一堂 tsconfig 配置课4.1compilerOptions里真正影响日常的几个开关tsconfig.json里的选项几十个但真正天天影响你手感的就那么几个先列个表对照一下。选项作用建议strict打开一组严格检查的总开关新项目必开target编译输出的语法版本按运行环境定一般ES2020起module模块格式现代项目用ESNext或NodeNextmoduleResolution模块解析策略与module配套别乱设esModuleInterop让import兼容 CommonJS基本都开skipLibCheck跳过.d.ts之间的检查建议开能省大量时间noUncheckedIndexedAccess索引取值结果加上undefined老项目慎开报错量大isolatedModules保证每个文件可单独转译用打包工具时建议开skipLibCheck这个值单独说一下。它跳过的是node_modules里.d.ts文件之间的相互检查不影响你自己代码的类型检查。很多项目被第三方依赖的类型冲突折磨到构建失败最后都是靠它解决的。它不开的时候你可能会遇到两个依赖都声明了同名全局类型然后互相打架报错信息还指到node_modules里根本没法改。4.2baseUrl为什么会被标记弃用baseUrl最早的作用是给非相对路径的模块解析指定一个基准目录。开了它之后import { foo } from utils/foo这种写法就能从baseUrl指定的目录开始找。听起来挺好问题在于它同时承担了两件事既是模块解析的基准目录又会影响paths的解析行为。这种双重语义带来的直接后果是配置难以理解。同一个paths配置开了baseUrl和没开baseUrl的行为不一样路径到底从哪个目录算起得看两个选项的组合。项目一多、工具链一换tsc、vite、jest、eslint各自解析路径的规则还不完全一致编辑器里能跳转、构建时找不到模块这种问题就来了。新的方向是把这两件事拆开paths可以不依赖baseUrl独立使用路径解析的基准就是tsconfig所在的目录需要给子路径起别名可以用package.json里的imports字段。baseUrl会在TypeScript 7.0中停止工作所以新项目就别再往上写了老项目可以趁着一次配置整理顺手迁掉。迁移时记住一个要点tsc的paths只管类型解析不会改写运行时的import语句打包器那边得同步配置一份别名两边对齐才不会出问题。4.3strict到底开不开一个能落地的判断标准这个问题我的答案一直很明确新项目无条件开老项目分批开。新项目开strict的成本几乎为零因为你从第一行代码开始就在这套规则下写不会觉得别扭。收益是长期的strictNullChecks会强制你处理所有可能为null的返回值noImplicitAny会阻止any悄悄扩散strictFunctionTypes会拦住函数参数类型不兼容的赋值。这些检查挡住的每一处都是本来要在测试或线上才能发现的问题。老项目分批开的顺序我一般这么排先开noImplicitAny把隐式的any显式写出来哪怕是写成any至少是可见的再开strictNullChecks这一步报错最多但收益也最大通常配合逐步给函数补返回类型最后开其余的strictFunctionTypes、strictBindCallApply、noImplicitThis这些。每开一个都在 CI 里锁住不允许回退。整个过程可能要几周甚至几个月但每开一个就是一个不可逆的进步比一次性全开然后退回false有用得多。4.4 编译、打包、isolatedModules之间的关系有个认知必须先纠正tsc不负责打包。它只做两件事——类型检查以及把.ts转成.js。模块之间的依赖关系怎么组织、代码怎么分包、怎么压缩是vite、webpack、rspack这些工具的活。这就带来一个约束打包工具是逐个文件转译的它不掌握全项目的类型信息。而TypeScript有些语法特性需要看到全局信息才能正确处理典型的就是const enum——tsc会把使用处直接替换成常量值但打包工具做不到只能给它生成一个运行时对象行为就变了。isolatedModules这个选项就是用来在编译期拦住这类不一致的打开之后凡是无法被单文件安全转译的写法都会报错。常见的触发点有两个一是const enum二是只导出类型的文件需要用export type显式标记。现在还有一个verbatimModuleSyntax选项更严格它会要求你把所有纯类型导入都写成import type好处是编译产物里的import语句跟源码完全一致不会出现编译后类型导入被删掉导致副作用丢失这种诡异问题。// 明确告诉编译器这个导入只是类型运行时不需要 import type { UserDTO } from ./types;5. 选型和迁移不是所有代码都值得上 TypeScript5.1 这些场景JavaScript 其实更划算网上关于TypeScript的讨论经常走向不用就是落后但按我的实际经历有几类场景用它反而是负担。一次性脚本最典型。几十行的数据清洗、一个临时的爬取整理脚本、一次性的文件批量重命名写JavaScript直接node xxx.js就跑不用管类型定义、不用配编译。给这类代码补类型的时间可能比写代码本身还长。快速验证原型也是。想法还没定型的时候数据结构一天变三回这时候类型定义就是在追着一个移动靶跑改一次类型比改业务代码还累。比较合理的做法是先用JavaScript把流程跑通等接口稳定了再有选择地迁移。还有就是没有维护预算的老项目。如果一个项目半年后就要下线或者只有一个人偶尔改两行硬上TypeScript带来的收益覆盖不了迁移成本和后续的理解成本。真正需要的是把测试补上而不是把类型补上很多时候这两件事只能做一件。团队情况也得考虑。如果团队成员对类型系统不熟写出来的TypeScript大概率是any满天飞那还不如老老实实写JavaScript至少不会给人这项目有类型保护的错觉。有类型保护的错觉比没有类型保护更危险。5.2 这些场景TypeScript 的收益会立刻显现第一类是多人协作、长期维护的项目。类型在这里最大的价值不是防 bug而是当文档用。你接手一个陌生模块看函数签名就知道要传什么、会返回什么不用翻实现你把一个字段改名编译器会把所有用到它的地方标出来不会漏。这个价值随着项目规模和时间线性增长。第二类是**typescript nestjs这种后端场景**。NestJS本身就是为TypeScript设计的依赖注入靠装饰器元数据请求体校验靠DTO类配合class-validatorSwagger文档能直接从类型推导出来。这三件事串起来之后定义一个 DTO → 自动校验 → 自动生成接口文档 → 前端拿到类型定义是一条完整的链路用JavaScript写这三步得全靠手写出错概率和重复劳动都很高。第三类是组件库、SDK、工具包。这类东西的使用者不是你你没法口头告诉他参数怎么传。类型定义就是你的说明书.d.ts文件会直接出现在别人的编辑器提示里。而且类型定义本身也是版本管理的一部分改了什么一目了然。第四类是前后端共享类型定义。接口返回结构这件事用JavaScript的时候通常靠一份手写的文档或者口头约定接口一改文档就过期。用TypeScript可以把共享类型放在一个独立的包里前后端都引它改一处两边都感知到。5.3 渐进迁移的可执行路线老项目迁移一次全改基本等于项目停摆。我会按这个顺序推第一步加tsconfig.json把allowJs打开checkJs先关strict先关。这一步的目标是让项目能跑通tsc不改任何代码。第二步开checkJs配合JSDoc给关键函数补注释。这一步不用改文件后缀就能在.js文件里获得一部分类型检查投入产出比最高。/** * param {string} name * returns {string} */ function greet(name) { return hello name; }第三步按目录逐个把.js改成.ts优先选依赖少、逻辑独立的基础工具目录。每个目录改完跑一遍测试和构建确认没问题再进下一个。第四步开noImplicitAny显式处理所有隐式any再开strictNullChecks这一步工作量大但每解决一处都是真实收益。第五步把tsc --noEmit放进 CI配置成不允许失败。这一步之后类型检查才真正有了约束力。5.4 迁移过程中真实踩过的坑第三方库没有类型是最常见的。先查types/包名有没有现成的没有再考虑自己写一个最小声明// types/legacy-lib.d.ts declare module legacy-lib { export function init(options: { key: string }): void; export default { init }; }只声明你实际用到的成员就行不用把整个库的类型都补齐。全局类型冲突也很烦。两个依赖都往全局挂了同名接口或者你写的全局声明和某个types撞了报错信息常常指向node_modules。处理办法有两个一是用skipLibCheck把依赖之间的冲突跳过二是给自己的全局类型加命名空间隔离别直接往Window上堆。循环依赖加上类型导入是个隐蔽的坑。A.tsimportB.tsB.ts又 importA.ts编译能过但运行时可能拿到undefined。解决办法是分析清楚依赖方向把共享类型抽到第三个文件或者用import type明确断掉运行时依赖。enum的取舍前面提过了。const enum在isolatedModules下会报错普通enum会生成运行时对象。我的建议是能用联合类型就别用枚举type Status pending | active | closed;联合类型编译后完全消失不占运行时体积和字符串字面量配合使用体验更好做穷尽检查也方便。6. 面试再问TS 和 JS 的区别我会这样分层回答6.1 背答案的版本问题出在哪TS 是 JS 的超集TS 有静态类型TS 需要编译——这三句话都对但它们属于定义层面的答案面试官问这个通常不是想听定义而是想验证你有没有真在项目里用过。所以接下来的追问往往才是重点编译完类型还剩什么any和unknown的区别是什么interface和type你怎么选TypeScript能不能做运行时校验。如果只会背定义第一个追问就会卡住。而只要讲清类型擦除这一条后面几个问题的答案基本是自然推出来的因为类型编译后就没了所以不能做运行时校验因为要继续用就得先收窄所以unknown比any安全因为interface支持声明合并、type支持联合和条件类型所以选哪个取决于你要不要这些能力。6.2 一段能体现真用过的回答框架我会按四层来组织每层一两句话听起来结构清晰又不啰嗦。语法层TypeScript在JavaScript基础上加了类型标注、接口、枚举、泛型、访问修饰符这些语法。这些语法是TypeScript独有的浏览器不认识必须编译。类型层JavaScript的类型检查在运行时而且很宽松访问不存在的属性只会得到undefinedTypeScript把类型检查提前到编译时并且用的是结构类型系统不看名字看形状。运行层类型信息在编译后完全擦除产物就是普通JavaScript。所以TypeScript保证的是源码层面的正确性运行时数据长什么样它管不了要校验得上zod、class-validator这类工具。工程层TypeScript真正的价值在协作和维护上——类型当文档用重构有编译器兜底接口定义可以在前后端之间共享。它的成本是多一层编译配置和类型维护工作项目越长期越划算。6.3 高频追问的答题要点面试问题回答的关键落点类型擦除是什么编译后类型信息全部消失产物是纯JSany和unknown区别any关检查unknown强制收窄后再用never用在哪穷尽检查、抛出异常的函数返回值interface和type怎么选interface支持声明合并、适合对象形状type支持联合、交叉、条件类型为什么不能运行时校验类型已擦除运行时没有类型信息enum有什么问题普通enum生成运行时对象const enum在isolatedModules下不可靠优先用联合类型declare global什么时候用给.d.ts补全局声明文件必须先是模块tsc和打包工具的分工tsc只做类型检查和转译打包交给vite/webpack6.4 两个能加分的细节如果面试里想再加一点印象分可以主动提这两个点。一个是类型守卫和运行时校验的组合用法。外部数据进来先用unknown接住写类型守卫函数收窄两者配合起来能做到编译期有类型、运行时也踏实function isUser(v: unknown): v is { id: number; name: string } { return typeof v object v ! null typeof (v as any).id number typeof (v as any).name string; }另一个是关于 AI 辅助写代码的观察。现在很多工具能根据上下文自动补类型确实省事但它补出来的类型经常是能过检查而不是语义正确——比如把本该是unknown的地方补成any把本该收窄的判断直接补成非空断言看着红色波浪线没了实际风险还在。所以即便有工具帮忙类型写的对不对还是得自己判断。最后说一句个人体会。我用了这么多年TypeScript最大的收获不是少写了多少 bug而是被迫把数据结构的边界想清楚。写JavaScript的时候一个对象里到底有哪些字段、哪些可能为空、哪些是别的模块塞进来的全靠脑子记写TypeScript的时候这些东西必须落到代码里。这个过程刚开始会觉得烦尤其是给一个糊里糊涂的老模块补类型的时候一边补一边发现原来这里的字段可能是undefined啊那种后背发凉的感觉恰恰就是以前埋在代码里的雷。类型系统本身不产生价值是它逼出来的那份清晰产生价值。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →