跳到正文

我所理解的 TypeScript 最佳实践(1):为什么选择 TypeScript

我所理解的 TypeScript 最佳实践

  1. 为什么选择 TypeScript(当前)
  2. 严格模式
  3. 状态、映射与泛型
  4. 模块边界与 TypeScript 脚本
  5. 类型检查、Lint 与测试
  6. TypeScript 7 升级与工具链兼容

站在 2026 年回看 JavaScript,很难把今天的它和 1995 年的浏览器脚本放在一起想。三十年前,网页可以在用户点下按钮时执行一小段程序。后来,地图可以边拖边加载,邮件可以在同一张页面里打开,浏览器里的语言也能跑到服务器上。到了今天,同一个代码库里,从网页组件到命令行工具,再到服务器接口,都可能写着 JavaScript。

每一次扩张都给开发者多了一件能做的事,也多了一些需要共同维护的约定。TypeScript 要回答的问题就出在这里:当一个值从一个人的函数走到另一个人的模块,再走到第三方的库里,我们怎样知道这些代码对它有着相同的理解?

从网页上的几行脚本,到不必刷新的应用

1995 年,Brendan Eich 在 Netscape 开发了后来被称为 JavaScript 的语言。浏览器厂商各有自己的实现,1997 年发布的 ECMA-262 第一版为语言建立了共同标准。那时网页脚本就能做表单校验、响应点击、改变页面内容,开发者可以在很短的反馈循环里看到结果。关于早期浏览器实现和标准化的过程,可以顺着两位参与者的《JavaScript 的前二十年》往下读。

到了 2000 年代中期,Ajax 让应用可以在用户留在同一张页面时向服务器取数据。对用户来说,是拖动地图时周边的街区接着出现。对开发者来说,是要在同一个页面里同时维护请求、数据、界面状态和用户操作。2009 年出现的 Node.js 又让 JavaScript 可以运行在浏览器之外。2015 年,ECMAScript 2015把模块等能力写进了标准。源文件之间有了清楚的导入和导出,团队也开始更频繁地面对另一层问题:导入的函数到底接受什么,返回的数据又有哪些保证?

假如一段代码用数字表示用户 ID,后来服务器开始返回字符串。对一个小脚本,改几处调用就能完成迁移。对一个有路由、组件、请求模块和独立发布的工具库的应用,难点在于找齐受影响的调用方。JavaScript 允许运行时的值变化。开发者需要另外说明哪些变化在这条链路上是合法的。

JSDoc 先把约定留在源码旁边

JSDoc 的独立项目至少可追溯到 2001 年 7 月 6 日,这是旧 SourceForge 项目的登记日期,没有足够的记录把这一天认定为首版发布日期。同一时期的早期做法可以从 Mozilla Rhino 的旧示例文档看到:示例脚本 jsdoc.js 被描述为 Java 的 Javadoc 在 JavaScript 里的对应物。今天常见的 JSDoc 3 则有明确的首版记录,官方变更日志把 3.0.0 记在 2012 年 5 月。

说它借鉴 Javadoc 是准确的,主要继承的是文档写法。Java 的 Javadoc 注释用 /** ... */ 和 @param 等标签描述声明,生成器从源码读取这些信息。Java 方法的参数类型已经写在签名上,JavaScript 的函数则往往需要在注释里补写 {string} 这样的类型。这也给后来的静态检查器留出了入口。

JSDoc让参数和返回值的说明有了统一的写法,工具可以从注释生成 API 文档。随后,同一份注释也能参与静态检查。TypeScript 2.3引入了对 JavaScript 文件的可选检查。加上 // @ts-check 后,检查器就可以指出下面这个函数的调用者传错了参数。

// @ts-check

/** @param {string} id */
function getUserPath(id) {
  return '/users/' + encodeURIComponent(id);
}

这一步的价值是让约定可以被验证,同时保留原来的 JavaScript 文件和执行方式。给已有的 JavaScript 加检查,可以在迁移之前先找出参数和返回值的分歧。到了新写的自有代码,我会直接用 TypeScript,让函数签名、共享类型和调用点从第一天就能一起检查。

TypeScript 和 Flow 在同一个时期回答了什么

微软在 2012 年 10 月公开 TypeScript,并于 2014 年发布 1.0。Facebook 在同年 11 月公开 Flow。二者都试图把一部分运行时错误提前到编辑和检查阶段,也都允许团队逐步引入类型。这段历史是两条路线同时发展。它们面对的是同一个变化:JavaScript 的工作已经超出了几个开发者靠读遍源码就能掌握的范围。

Flow 的出发点很具体。Facebook 希望给已经运行的大量 JavaScript 加检查,又不想要求开发者先给整库手写类型。2014 年的发布公告把重点放在数据流、控制流和类型推断上。文件可以逐步纳入检查,工具也能利用分支条件推断一个值在当前位置可能是什么。源码若带 Flow 类型语法,构建工具会擦掉注解再交给 JavaScript 运行。TypeScript 和 Flow 都允许渐进采用,只是两者起步时对推断、语法和工具链的取舍不同。

TypeScript 选择让 JavaScript 代码可以逐步进入同一套语言和工具链。把上面的函数签名写成 getUserPath(id: string) 后,编辑器可以直接从声明拿到参数类型。更关键的是,项目还可以为已经存在的 JavaScript 库提供 .d.ts 声明。库按原来的方式运行,使用者可以在自己的编辑器和检查器里获得参数、返回值和跨模块的类型关系。据 TypeScript 1.0 发布公告,当时社区已经积累了数百份这样的声明。

Angular 2 把类型工具带进框架设计

2014 年,Facebook 用 Flow 回答大型 JavaScript 项目的类型问题,Google 也有自己的实践。Closure Compiler 使用 JSDoc 类型注解,一方面给出类型警告,一方面把这些信息用于优化。Angular 团队在为 AngularJS 1.x 的后继框架重新设计组件和依赖注入时,另外提出了 AtScript。它沿用 TypeScript 风格的类型标注,并设想增加元数据注解和运行时检查。静态检查要回答的是调用是否符合声明,元数据则要让框架在代码真正运行时也能读到类和参数的信息。Closure Compiler 是 Google 已经使用的 JavaScript 工具,AtScript 则是 Angular 团队在这次设计中提出的语言路线。

Angular 想让框架在看到一个类时就知道怎样使用它。比如 @Component(...) 可以把选择器和模板配置放在类旁边,而依赖注入还需要知道构造函数依赖哪个服务。2015 年 3 月,双方在 ng-conf 宣布 Angular 2 将采用 TypeScript。微软的公告记录了数月合作,明确提到类注解、运行时类型信息和依赖注入。TypeScript 1.5随后以 experimentalDecorators 提供当时仍在演进的装饰器提案实现,Angular、Ember 和 Aurelia 团队都参与了这项工作。公开材料能说明合作的结果和双方的技术需求,至于谁先找谁、有没有明确的交换条件,现有材料不足以还原那段谈判。

这也是理解 TypeScript 和 ECMAScript 关系的好例子。TypeScript 的设计目标强调跟随 ECMAScript 的演进,Angular 时期的装饰器则通过实验开关提前交给项目使用。后来提案的调用签名和行为发生变化,TypeScript 5.0加入了基于 Stage 3 提案的装饰器,同时保留旧开关供既有项目使用。同样写作 @decorator,两套实现的类型检查和编译输出不同。新提案的装饰器不支持参数装饰器,也不能与 emitDecoratorMetadata 一起使用。

旧装饰器路线还提供了一个值得分辨的能力。开了 emitDecoratorMetadata以后,编译器可以为部分被装饰的声明生成 design:paramtypes 等运行时元数据。reflect-metadata提供保存和读取这些元数据的接口。比如参数声明为某个服务类,生成的 JavaScript 可以把该类的构造函数放进元数据,供依赖注入容器读取。接口和泛型参数没有对应的运行时构造函数,不能期待这套机制把完整类型系统搬到运行时。JavaScript 本身也能手动登记同样的元数据,TypeScript 在这里提供的是根据源码类型自动生成登记代码的能力。TypeScript 5.2 支持的 Symbol.metadata 路线则让装饰器写入自己的元数据,和旧路线的自动类型元数据各有用途。

VS Code 和声明生态让反馈回到语言本身

Angular 让框架的需求直接进入语言设计,VS Code 则提供了另一种反馈。微软在 2015 年发布 VS Code 时,编辑器已经内置对 TypeScript 1.5 Beta 的语言支持。它自己的编辑器源码也用 TypeScript 编写。工具团队每天既在写 TypeScript,也在维护补全、跳转和重构这些功能。大型代码库里的慢检查和编辑延迟,可以在他们自己每天使用的工具里直接观察。

这种反馈延续到了原生编译器。2026 年,VS Code 团队把它的主源码交给 TypeScript 7 检查,在他们公开的对照中,一次主源码类型检查由 36 秒降到 5 秒。这是特定项目的实测。它能说明为什么编译速度会改变大型项目的工作习惯,修改一个共享接口后,开发者更容易顺手跑一轮全量检查。

另一块拼图是社区维护的 DefinitelyTyped。到 TypeScript 2.0,声明可以通过 npm 的 @types 分发。库作者可以随库发布类型,社区也可以为既有 JavaScript 库补声明。应用拿到声明以后,同一份接口就能同时服务编辑器补全、重构和 CI 检查。Angular 和 VS Code 展示了大型项目怎样推动语言变得更实用,声明生态让普通项目也能享受到这套工具。

Vue 和 React 走出了不同的路线

Vue 和 React 后来的选择,显示了这条路为什么能跨越源码的语言边界。

到 2025 年 8 月,TypeScript 已经按月贡献者数量排在 GitHub 语言榜首。这个数字描述的是 GitHub 上的参与规模。落到一个新项目,更有用的变化是关键依赖已经有类型入口,团队可以用同一种检查方式贯穿自己的代码和这些依赖。

为什么越来越多的项目转向 TypeScript

从迁移记录看,决定因素往往在类型系统之外。先是迁移成本。 2018 年 Babel 7 加入 TypeScript 转译支持,已有 Babel 项目可以继续用原来的构建流程擦除类型,再独立跑 TypeScript 检查。Flow 同样有自己的 Babel preset。这项变化让原本依赖 Babel 的团队更容易尝试另一条路线。

再是日常协作。 Yarn 团队在 Yarn 2 的开发计划里明确说,Flow 在团队内部用得不错,迁移到 TypeScript 是希望让外部贡献者面对更熟悉的代码。Airtable 则把迁移工具开源,它甚至会向 Flow 查询推断出的类型,再生成 TypeScript 注解。前者说明为什么换,后者说明换过去仍然需要处理真实的类型差异。

最后是采用后的反馈。 前面提到的 @types、框架声明、编辑器语言服务和 Babel 支持,让应用可以沿用熟悉的构建方式,又能复用依赖提供的类型。团队越容易招到熟悉这套工具的人,就越愿意让新模块也用它。更多库开始提供声明之后,下一批应用接入的成本也随之下降。这是从这些项目的选择推出来的一种解释。GitHub 的贡献者数据能反映采用规模,无法单独证明某一种类型设计决定了结果。

所谓 TypeScript 在开放生态里占了上风,说的是框架、依赖、编辑器和开发者逐渐汇聚到同一套接口上。Flow 仍有公开版本发布,React 源码和 React 应用的分工也是一个反例,说明生态默认选择的变化并不要求所有代码一起迁移。

为什么我现在会主动用 TypeScript

如果组件库把 onSelect 的参数从一个 ID 改成选中项对象,类型检查能指出哪些调用方仍把它当作字符串。改接口时也是一样。用鼠标跳到定义,能看到一份所有调用方共同依赖的声明。改完再跑检查,可以找到改动越过了哪些模块边界。静态类型让这些原本需要反复翻源码的工作,有了日常可以执行的办法。

所以在我主导的 JavaScript 生态项目里,从应用组件到服务端、CLI、插件、构建脚本和测试,自有维护源码都用 TypeScript。工具链支持的可执行配置也一样。局部变量继续交给推断,跨模块传递的参数、返回值和可能缺失的字段由编译器一起检查。

脚本的行数常常掩盖了它对外部约定的依赖。一个首页插件的核心只是一行 filter,但它接收的是 Hexo 的文章查询对象,主题还可能传入另一份已经筛过的集合。把这个参数写进类型,调整生成器时就能沿着调用点看清代码依赖哪份集合。类似的短脚本还有发布入口和数据迁移,它们每次执行都要读写真实内容,通常也会被同一个仓库反复使用。

采用同一种源码语言之后,文件由谁执行仍然要单独判断。仓库内部的 Node 脚本默认用 tsx,独立打包的 Node 工具则由 Node 运行构建后的 JS。浏览器和插件宿主也各有自己的入口。拿到别人维护的代码库,我先沿用它的约定,再根据真实依赖决定迁移范围。各环境的执行方式见《模块边界与 TypeScript 脚本》。

下一篇从一个很常见的情况开始。函数声明说它会返回一个用户,但数据库没有查到这个人时,我们希望谁先发现问题?这也是理解 TypeScript 为什么需要严格模式的起点。