
1. 从 JS 到 TS先想清楚这几笔账再动手1.1 TypeScript 真正解决的问题是团队协作成本先泼一盆冷水如果项目只有你自己写、自己读、自己改而且代码量在两三千行以内TypeScript 带来的收益可能并不明显甚至会觉得有点烦。但一旦项目超过这个体量或者有第二个人加入情况就完全不同了。我在实际项目里遇到过最典型的一次事故同事写了一个工具函数参数是个配置对象内部悄悄改了某个字段的类型——从字符串改成了数字。调用方没有任何提示代码在运行时一切正常直到某个联动模块用这个字段去拼接字符串才突然冒出NaN。排查花了一整个下午最后定位到改动源头时同事自己也愣了半天他根本没意识到那个字段还有别的模块在用。这就是 TypeScript 存在的理由。它不解决逻辑错误不解决性能问题它解决的是信息传递断裂的问题——让函数与函数之间、模块与模块之间、人与代码之间的约定变得可见、可查、可强制。你写的每个类型标注本质上是在告诉未来的维护者包括三个月后的你自己这里进来的数据长什么样出去的数据长什么样不接受意外。还有一个容易被低估的收益重构安全性。JS 项目里改一个函数签名你只能靠全局搜索加肉眼确认TS 项目里改完类型所有不兼容的调用点会同时亮起红线。这个体验一旦习惯了会很难回去。1.2 成本曲线类型标注带来的编译期开销是否值得当然转型也不是零成本。TypeScript 需要编译、需要安装依赖、需要写额外注解这些听上去都是负担。但我实测下来的结论是正常业务代码的 TS 标注成本大约占总代码量的 5%~10%而换来的收益远高于这个数字。先看编译成本。tsc的编译速度确实比 Babel 这类纯转译工具慢但现在绝大多数现代构建工具链里TypeScript 并不需要每次都全量编译开发模式下用的是esbuild或swc这类基于原生代码的转译器速度快到几乎无感。类型检查单独跑通常在 CI 或保存时执行就够了。再看学习成本。对于已经熟悉 JavaScript 的开发者TypeScript 本身没有引入新的运行时概念——它只是在 JS 之上加了一套静态描述系统。你不需要重新学习事件循环、闭包、原型链你只需要学会怎么描述一个值的形状。这更像是给车加装仪表盘而不是换一台发动机。最后说人力成本。团队里如果有新手TS 反而能降低他们的认知负担类型本身就构成了文档函数怎么调、参数传什么编辑器里一眼就能看懂。从长期维护的角度看这笔投入是一定回本的。2. 类型标注速查变量的写法决定后续所有体验2.1 基础类型与自动推断先看最基础的写法。JS 里声明变量let count 0; const name hello; let flag true;TS 里可以直接沿用因为 TypeScript 会做类型推断。count自动推断为numbername为stringflag为boolean。如果你需要显式标注let count: number 0; const name: string hello; let flag: boolean true; let data: null null; let content: undefined undefined;这里要强调一个关键区别在 JavaScript 里null和undefined是两个独立的值判断类型时经常要用 null或者typeof x undefined去区分在 TypeScript 里它们同样是对应类型标注。但如果你开启了strictNullChecks后面细说情况会复杂一些——null和undefined不能直接赋给一个声明为number的变量。还有一个容易忽略的点bigint和symbol。TS 同样支持这两个 ES6 的类型let bigNumber: bigint 9007199254740993n; let uniqueKey: symbol Symbol(id);2.2 对象、数组、元组和枚举这是从 JS 到 TS 的第一个有认知门槛的地方。JS 里数组就是数组里面装什么都行TS 需要对数组元素的类型做出限定let nums: number[] [1, 2, 3]; let strs: string[] [a, b];\n let mixed: Arraynumber | string [1, two]; // 联合类型数组除了number[]这种写法还有Arraynumber这种泛型写法。两种等价风格问题。推荐团队里统一避免混用。对象类型可以这样标注let user: { name: string; age: number } { name: 小明, age: 18 };元组Tuple是 TS 独有的概念对应 JS 里固定长度和顺序的数组let httpStatus: [number, string] [200, OK]; httpStatus[0].toFixed(2); // number 的方法 httpStatus[1].toUpperCase(); // string 的方法这个类型特别适合用于 API 返回值、坐标点这类位置有意义的数据。还有一个细节元组还能用readonly修饰保证内容不可变let rgv: readonly [number, number] [1, 2];枚举也是从 JS 到 TS 常被问到的点。如果你用 JS 写过const STATUS { PENDING: 0, SUCCESS: 1, FAILED: 2 };TS 里有对应写法enum Status { PENDING 0, SUCCESS 1, FAILED 2 }要注意枚举在运行时是真实存在的对象不是纯类型层面消失的东西。对于追求运行时零开销的场景可以考虑使用const enum或者直接使用字面量联合类型后面会讲。2.3 interface 与 type 的区别以及 interface 怎么继承搜索热词里出现了typescript interface 怎么继承这确实是高频面试题也是实际开发中绕不开的写法。先看最简单的方式extends。interface Animal { name: string; age: number; } interface Dog extends Animal { breed: string; } const myDog: Dog { name: 旺财, age: 3, breed: 柴犬 };这里Dog继承了Animal的所有属性同时新增breed。注意不是覆盖是叠加。如果父接口里有个属性是number子接口想改成stringTS 是不允许的这会报错。除非用泛型提高父接口的抽象程度或者明确用类型交叉来覆盖。多个接口之间可以多重继承interface Pet { owner: string; } interface Dog extends Animal, Pet { breed: string; }然后再说interface和type怎么选。这个问题每个 TS 项目几乎都要面对我的经验是分两层看第一语法能力。type可以做到、interface做不到的联合类型、交叉类型、条件类型和映射类型。比如type ID number | string; // 联合类型 type User { name: string } { age: number }; // 交叉类型 type NullableT T | null; // 泛型工具而interface有、type没有的声明合并Declaration Merging。也就是说两个同名的interface会自动合并这在扩展第三方库或全局类型时非常有用。第二使用惯例。社区里比较普遍倾向于描述对象的形状用interface描述联合类型、元组、映射结果或者更复杂的类型计算用type。实际上二者在绝大多数场景下可以互换我个人认为保持团队统一比纠结哪种更正确更重要。2.4 函数类型参数、可选参数、默认值和重载函数是 JS 里最关键的部分TS 里函数标注有几个最常用的写法function add(a: number, b: number): number { return a b; } const add (a: number, b: number): number a b;可选参数用?标记同时注意可选参数必须放在必选参数后面function greet(name: string, age?: number): string { if (age ! undefined) { return ${name}${age}岁; } return name; }默认参数可以直接在声明时指定TS 会推断出类型function createUser(name: string, age: number 18) { return { name, age }; }还有一个实用但很多人不知道的点函数类型本身可以声明为一个独立的别名这在传递回调函数时非常好用type CallbackFn (err: Error | null, data?: string) void; function fetchData(cb: CallbackFn) {}函数重载在 JS 里没有直接对应但用 TS 可以让同一个函数在不同参数签名下返回不同类型。常见的写法是先把多个签名列出来再写实现function format(input: string): string; function format(input: number): string; function format(input: string | number): string { return String(input); }这个特性在写库和工具函数时很香。3. 类型判断与守卫速查typeof、instanceof、in 各管一摊3.1 三种判断方式的适用边界从 JS 带过来的第一反应可能就是typeof。但它并不是万能的typeof []返回objecttypeof null返回object这是 JS 的老坑TS 也要面对。在 TS 里用typeof做类型守卫时只能区分string、number、boolean、symbol、bigint、undefined和function对于对象、数组、null 无法精确区分。所以见到typeof x object你还需要进一步收窄。区分数组和普通对象用Array.isArray()TS 对这个有原生支持会直接收窄到any[]function process(data: unknown) { if (Array.isArray(data)) { data.map((item) item); // 这里 data 已经被收窄为数组 } }区分对象实例的类型用instanceof。这在判断一个值是Date、Map、RegExp或其他类实例时特别直接function handle(value: Date | string) { if (value instanceof Date) { value.getTime(); // 正确value 被收窄为 Date } else { value.toUpperCase(); // 正确value 被收窄为 string } }第三种是in操作符它的场景是判断对象是否有某个属性/方法。对于接口和联合类型in是极其有效的收窄手段type Fish { swim: () void }; type Bird { fly: () void }; function move(animal: Fish | Bird) { if (swim in animal) { animal.swim(); // animal 被收窄为 Fish } else { animal.fly(); // animal 被收窄为 Bird } }3.2 类型守卫与可辨识联合上面用的typeof、instanceof、in都属于内置类型守卫。但实际业务里有很多场景需要自定义守卫——TS 提供了一种语法类型谓词type predicate。function isString(value: unknown): value is string { return typeof value string; } function print(value: unknown) { if (isString(value)) { value.toUpperCase(); } }这里的value is string告诉 TS如果这个函数返回true那么参数value的类型就是string。这比让 TS 靠推断更精确也极大提升了复杂条件下代码的可读性。可辨识联合Discriminated Union是类型守卫的高级模式。它要求每个联合成员有一个固定的字面量字段作为标签然后用switch或if判断标签来收窄。type ApiResponse | { status: success; data: any } | { status: error; errorCode: number }; function handleResponse(response: ApiResponse) { switch (response.status) { case success: console.log(response.data); break; case error: console.log(response.errorCode); break; } }只要标签字段是字面量类型TS 就能自动把每个分支里的response收窄成对应的类型。这种模式最适合处理异步请求结果、表单校验和多态列表。3.3 any、unknown、never差别很大这三个类型是面试题也是实际项目里最容易出幺蛾子的地方。any是关闭类型检查的逃生舱赋值给任何类型都不会报错反过来怎么操作也不会报错。听着很方便但对整体类型系统是破坏性的。如果你在项目里大量使用anyTypeScript 的价值会打对折。我的建议是能用unknown就尽量别用any。unknown是未知类型的安全版。它比any严格不能直接赋值给其他类型也不能直接调用方法或访问属性必须先做类型守卫或断言。强制你在使用前确认类型从而避免隐藏的类型错误。let data: unknown; data.toUpperCase(); // 报错data 是 unknown不能直接调用方法never表示永远不可能出现的值。它在 TS 里的作用主要有两个一是标记一个永远抛错的函数function fail(message: string): never { throw new Error(message); }二是用于穷尽检查exhaustive check。当你写switch表达式走完所有分支后再用一个never类型兜底如果未来新增了联合类型成员而你没写对应的分支TS 就会报错function assertNever(x: never): never { throw new Error(Unexpected value: x); }4. 存量项目直接转 TypeScript 的增量路线4.1 不要一上来就重写存量项目转 TS最容易犯的错误就是花一个月先重写成 TS 再继续开发。实际上这种大规模重写往往会在中途遇到各种隐性依赖和动态特性导致交付延期、团队疲劳严重时项目就直接烂尾了。正确的做法是增量带病迁移让 TS 和 JS 并存逐步降低 JS 比例。TS 官方对这个问题早有考虑提供了多个低成本的开关让老项目可以先转后查。4.2 用 allowJs 和 checkJs 渐进式检查先安装依赖npm install typescript --save-dev然后生成 tsconfig.jsonnpx tsc --init关键配置有两组{ compilerOptions: { allowJs: true, checkJs: false, outDir: ./dist }, include: [src/**/*] }allowJs允许 TS 编译器处理.js文件这样你不用把文件改成.ts也能参与编译。checkJs默认是false开启后编译器会检查.js文件中的类型问题但不会阻断构建。你可以先全开checkJs看看到底有多少类型问题再决定按模块逐个关闭或修复。还有一种更轻量的方式在.js文件顶部加一行注释// ts-check这行注释会为该文件单独开启类型检查按文件挨个排查实现真正的逐文件推进。4.3 第三方库没有类型怎么办当你在 JS 项目里引入 TS 后最大的拦路虎通常是第三方的 npm 包没有类型声明。VS Code 会给你提示Could not find a declaration file for module xxx。处理方案有三个级别第一个级别安装类型声明包。大多数知名库都有社区维护的types/*包npm install types/lodash --save-dev第二个级别自己写一个声明文件。在项目里建types/目录写.d.ts文件declare module some-old-lib { export function parse(input: string): Result; export interface Result { ok: boolean; data?: any; } }第三个级别直接放宽类型约束。如果这个库实在太老、类型极难描述可以声明成anydeclare module ancient-lib;这个声明本质上是这个模块没有类型信息当作 any 用虽然不理想但比项目整个卡住要强。4.4 tsconfig 里真正影响迁移的几组选项tsconfig.json的配置项非常多我挑几个对迁移过程影响最大的说。第一组是严格相关strict这个总开关建议直接打开。如果一开始报错太多可以先设strict: false再把noImplicitAny、strictNullChecks等单独开启等有精力时逐步收紧。不过我的经验是既然花了时间迁移strict 早晚要开越晚开越痛苦。第二组是模块与输出相关module和target。如果项目用的还是 CommonJS 或 AMD要跟构建工具链保持一致target决定了转译出来的 JS 语法等级Node 环境比较新的话可以直接ES2020或ES2022。第三组是增量编译incremental: true和tsBuildInfoFile。开启后 TS 会缓存类型检查结果二次编译速度会快很多。在大型项目的后续迭代中这个配置几乎必开。5. 迁移后的高频报错和坑位排查5.1 可能为 null的红线strictNullChecks开启后全项目最常见的一条红线就是某个值可能是null或undefined不能直接访问属性。比如这个 JS 写法let config localStorage.getItem(config); let parsed config ? JSON.parse(config) : {};TS 下 JSON 解析的结果是anylocalStorage.getItem返回的是string | null于是你写const config localStorage.getItem(config); const parsed JSON.parse(config); // 报错config 可能为 null看似很烦但这条红线恰恰救过我好几次。解决方案有几种用if收窄if (config ! null) { const parsed JSON.parse(config); }用非空断言不推荐滥用但明确知道安全时确实简洁const parsed JSON.parse(config!);用默认值兜底const parsed JSON.parse(config ?? {});我个人的处理顺序是能收窄就先收窄能让逻辑更清晰实在不需要关心空值时用默认值非空断言用在明确由代码逻辑保证不为空的地方。5.2 联合类型上属性不存在的红线第二个高频报错类似这样type Config { type: a; value: string } | { type: b; size: number }; function getValue(config: Config) { return config.value; // 报错属性 value 在联合类型的部分成员上不存在 }这个红线的意义在于config可能是type: a也可能是type: b而value只在type: a上存在。正确解法是前面讲的可辨识联合function getValue(config: Config) { if (config.type a) { return config.value; } return config.size; }还有一种处理方式是用in或value in config来判断字段存在。但对于可辨识联合我更推荐写类型标签type字段因为它语义清晰、可以穷尽检查也方便在 switch 中收窄。5.3 DOM 操作和 this 绑定的坑在浏览器环境里写 JS 时会频繁操作 DOMTS 对 DOM 类型的检查比 JS 严格得多常见的报错有const video document.querySelector(video); video.style.rotate -90deg; // 报错video 可能为 null这个跟前面localStorage的例子同类关键是querySelector返回的是Element | null必须收窄或断言const video document.querySelector(video); if (video) { video.style.rotate -90deg; }还有一种情况是querySelector返回的是一个联合类型比如HTMLElement和SVGElement都有的场景需要强制指定const input document.querySelectorHTMLInputElement(#myInput);模版字符串里的泛型语法querySelectorT告诉 TS 返回的具体类型这样你调用.value就不会报错。这个写法的意思是我确信这个选择器返回的是一个 input 元素属于泛型的一种用法。this相关的问题在 TS 中也会因为strictBindCallApply之类的配置变得更严格。如果你的函数里有动态this可以给this显式标注类型这是 TS 独有的能力function updateConfig(this: { name: string }, value: string) { this.name value; }但这只是权宜之计。现代 TS 项目里更推荐使用箭头函数或显式传参替代动态this少踩很多坑。6. 实战代码速查从 JS 到 TS 的语法对照表6.1 最常用的语法对照下面这张表是我平时给团队培训时常列的覆盖了日常工作里 80% 的场景。可以直接作为速查工具用JavaScript 写法TypeScript 写法说明let x hellolet x: string hello显式标注也可省略const arr [1, 2, 3]const arr: number[] [1, 2, 3]数组元素类型限定function f(a, b) { return a b }function f(a: number, b: number): number { ... }参数和返回类型const f (x) x * 2const f (x: number): number x * 2箭头函数同样需要标注obj { name: a, age: 18 }interface User { name: string; age: number }对象结构描述enum Status { PENDING, OK }同上TS 直接支持枚举类型typeof x string同上TS 的typeof守卫可用判断原始类型x instanceof Date同上TS 的instanceof守卫可用判断实例类型if (in in obj)同上TS 的in守卫可用判断属性存在let data nulllet data: null null严格空值检查下注意区别try { ... } catch (e) { ... }catch (e: unknown) { ... }TS 推荐unknown捕获const obj { a: 1 }type T { a: number }类型别名6.2 用常见模式积累起来的“重写清单”从 JS 转 TS 的过程里很多代码不是一行行改而是整体替换模式。这里分享几个我实测最常用的模式。模式一配置对象用接口描述。JS 里一个对象配合Object.assign或展开运算符去写配置转 TS 时直接定义interface Config效果立竿见影。模式二事件处理函数用类型化回调。原生 JS 的事件回调参数是Event但具体到MouseEvent、KeyboardEvent时需要显式标注才能访问event.clientX之类的属性。模式三API 返回值用泛型封装。如果你有一个统一的请求函数不要裸返回any改成泛型。async function requestT(url: string): PromiseT { const res await fetch(url); return res.json() as PromiseT; } const user await requestUser(/api/user);模式四外部数据用unknown或自定义类型守卫。从localStorage、JSON.parse拿出来的数据TS 会把它视为any安全起见可以先用unknown接收再经过守卫收窄。最后补充一个几乎所有团队都会遇到的细节保持类型定义文件有序。把interface、type、枚举统一放在src/types/目录下按业务域拆文件而不是随意散落在组件文件里。这样维护起来不会失控也方便未来提取公共类型。我个人在实际操作中的体会是从 JS 转 TS 最大的挑战不是学语法而是改变代码的组织习惯——每次写函数之前先想清楚参数是什么、返回什么、可能的边界值是什么。这个过程一旦养成习惯写代码的速度可能反而会快一些因为很多低级错误在写的时候就被类型系统拦住了不需要调试阶段再返工。刚开始转的两周会有点别扭建议每天坚持把新增代码写成 TS旧的零散 JS 文件抽空就改一两个三个月后回头看你会感谢当时的自己。