接私活的时候最怕看到什么?打开项目目录,根目录躺着一个tsconfig.json,点进去一看"strict": false,再往下翻,src里.js和.ts混着放,同一个文件夹里a.js引b.ts,b.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里确实没有收录rotate,TypeScript报错了,但浏览器是支持的。这时候你才需要判断,到底是我的写法有问题,还是类型定义落后了。这个判断过程本身就是收获,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 节会细说。
| 对比维度 | JavaScript | TypeScript |
|---|---|---|
| 类型检查时机 | 运行时 | 编译时(编辑器 + 构建) |
| 类型错误的表现 | 抛异常或静默产生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?: Record<string, 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之外的任何值"。数字、字符串、数组、函数,全都能赋值给{}。想表达"空对象"得用Record<string, never>或者干脆定义一个明确的接口。理解错这一点,写出来的类型守卫就会形同虚设。
3. 写业务时真正分叉的地方:合并对象、动态调用、原型链
3.1 合并两个对象,深浅之间埋着两层坑
javascript合并两个对象这个需求高频得离谱,写法大致就那几个,但每个都有自己的脾气。
第一个是Object.assign:
const 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); // 'C',b 被连带改了真需要深拷贝,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' can't be used to index(noImplicitAny打开时)。想让索引合法,得给对象加索引签名:
const actions: Record<string, () => 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.querySelector<HTMLFormElement>('#login'); form?.addEventListener('submit', (e) => { e.preventDefault(); const data = new FormData(form); console.log(data.get('username')); });addEventListener的第二个参数里,e的类型会根据事件名自动推断,'submit'对应SubmitEvent,'click'对应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或NodeNext |
moduleResolution | 模块解析策略 | 与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. 选型和迁移:不是所有代码都值得上 TypeScript
5.1 这些场景,JavaScript 其实更划算
网上关于TypeScript的讨论经常走向"不用就是落后",但按我的实际经历,有几类场景用它反而是负担。
一次性脚本最典型。几十行的数据清洗、一个临时的爬取整理脚本、一次性的文件批量重命名,写JavaScript直接node xxx.js就跑,不用管类型定义、不用配编译。给这类代码补类型的时间,可能比写代码本身还长。
快速验证原型也是。想法还没定型的时候,数据结构一天变三回,这时候类型定义就是在追着一个移动靶跑,改一次类型比改业务代码还累。比较合理的做法是先用JavaScript把流程跑通,等接口稳定了再有选择地迁移。
还有就是没有维护预算的老项目。如果一个项目半年后就要下线,或者只有一个人偶尔改两行,硬上TypeScript带来的收益覆盖不了迁移成本和后续的理解成本。真正需要的是把测试补上,而不是把类型补上,很多时候这两件事只能做一件。
团队情况也得考虑。如果团队成员对类型系统不熟,写出来的TypeScript大概率是any满天飞,那还不如老老实实写JavaScript,至少不会给人"这项目有类型保护"的错觉。有类型保护的错觉比没有类型保护更危险。
5.2 这些场景,TypeScript 的收益会立刻显现
第一类是多人协作、长期维护的项目。类型在这里最大的价值不是防 bug,而是当文档用。你接手一个陌生模块,看函数签名就知道要传什么、会返回什么,不用翻实现;你把一个字段改名,编译器会把所有用到它的地方标出来,不会漏。这个价值随着项目规模和时间线性增长。
第二类是**typescript + nestjs这种后端场景**。NestJS本身就是为TypeScript设计的,依赖注入靠装饰器元数据,请求体校验靠DTO类配合class-validator,Swagger文档能直接从类型推导出来。这三件事串起来之后,"定义一个 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.ts,B.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的类型检查在运行时,而且很宽松,访问不存在的属性只会得到undefined;TypeScript把类型检查提前到编译时,并且用的是结构类型系统,不看名字看形状。
运行层:类型信息在编译后完全擦除,产物就是普通JavaScript。所以TypeScript保证的是源码层面的正确性,运行时数据长什么样它管不了,要校验得上zod、class-validator这类工具。
工程层:TypeScript真正的价值在协作和维护上——类型当文档用,重构有编译器兜底,接口定义可以在前后端之间共享。它的成本是多一层编译配置和类型维护工作,项目越长期越划算。
6.3 高频追问的答题要点
| 面试问题 | 回答的关键落点 |
|---|---|
| 类型擦除是什么 | 编译后类型信息全部消失,产物是纯JS |
any和unknown区别 | any关检查,unknown强制收窄后再用 |
never用在哪 | 穷尽检查、抛出异常的函数返回值 |
interface和type怎么选 | interface支持声明合并、适合对象形状;type支持联合、交叉、条件类型 |
| 为什么不能运行时校验 | 类型已擦除,运行时没有类型信息 |
enum有什么问题 | 普通enum生成运行时对象;const enum在isolatedModules下不可靠;优先用联合类型 |
declare global什么时候用 | 给.d.ts补全局声明,文件必须先是模块 |
tsc和打包工具的分工 | tsc只做类型检查和转译,打包交给vite/webpack |
6.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啊",那种后背发凉的感觉,恰恰就是以前埋在代码里的雷。类型系统本身不产生价值,是它逼出来的那份清晰产生价值。