作者:Rob Palmer,彭博社JavaScript基础设施和工具主管,TC39联席主席
几年前,彭博工程决定采用这种方法打印稿作为一流的支持语言。这篇文章分享了我们在这段旅程中学到的一些见解和教训。
标题是我们发现TypeScript是一个强大的净积极的!在阅读我们探索的一些令人惊讶的角落时,请记住这一点。作为工程师,我们很自然地被吸引去观察、解决和分享问题,即使我们玩得很开心

背景
在TypeScript出现之前,Bloomberg就已经在JavaScript上投入了巨大的资金——超过5000万行JS代码。我们的主要产品是彭博终端,它包含1万多个应用程序。应用程序种类繁多,从密集的实时金融数据和新闻显示到交互式交易解决方案和多种形式的消息传递。早在2005年,该公司就开始将这些应用程序从Fortran和C/ c++迁移到服务器端JavaScript, 2012年左右,客户端JavaScript出现了。今天,我们公司有2000多名软件工程师在编写JavaScript。

将这种规模的代码库从普通的JavaScript过渡到TypeScript是一件大事。因此,我们努力工作,以确保有一个深思熟虑的过程,使我们与标准保持一致,并保留我们现有的能力,以快速和安全地发展和部署我们的代码。
如果您曾经在一家大公司中参与过技术迁移,那么您可能已经习惯了使用严厉的项目管理来迫使不情愿从事新功能的团队取得进展。我们发现采用TypeScript是完全不同的。工程师们自发地开始转换并支持这一过程!当我们推出TypeScript平台支持的测试版时,仅第一年就有200多个项目选择使用TypeScript。没有一个项目被退回。
TypeScript的这种用法有何特殊之处?
除了规模之外,TypeScript集成的独特之处在于我们有自己的JavaScript运行时环境。这意味着,除了众所周知的JavaScript主机环境,如浏览器和Node,我们还直接嵌入V8引擎和Chromium来创建我们自己的JavaScript平台。这种情况的好处是,我们可以提供简单的开发体验,在这种体验中,我们的平台和包生态系统直接支持TypeScript。Ryan Dahl的Deno采用了类似的思路,将TypeScript编译放到运行时中,而我们将其保存在独立于运行时的工具中。一个有趣的结果是,我们可以在一个横跨客户端和服务器的独立JS环境中练习TypeScript编译器,并且不使用特定于node的约定(例如,没有node_modules
目录)。
我们的平台支持使用通用工具和发布系统的包的内部生态系统。这允许我们鼓励和执行最佳实践,比如默认为TypeScript的“strict模式”,以及确保全局不变量。例如,我们保证所有发布的类型都是模块化的,而不是全局的。这也意味着工程师可以专注于编写代码,而不是弄清楚如何让TypeScript与绑定器或测试框架良好地运行。DevTools和错误堆栈正确使用sourcemaps。测试可以用TypeScript编写,代码覆盖率可以用原始的TypeScript代码准确地表达。它只是工作。
我们的目标是让常规的TypeScript文件成为api的唯一真实来源,而不是维护手写的声明文件。这意味着我们有很多代码严重依赖于TypeScript编译器的自动生成.d.ts
TypeScript源代码中的声明文件。因此,当declaration-emit不理想时,我们会注意到它,正如您将看到的。
原则
让我们来概述我们正在努力争取的三个关键原则。
⚖️可伸缩性:随着越来越多的包采用TypeScript,开发速度应该保持在较高水平。应该尽量减少安装、编译和检查代码的时间。
☮️生态系统的一致性:软件包应该一起工作。升级依赖应该是毫无痛苦的。
标准对齐:我们希望坚持使用ECMAScript等标准,并为它们的下一步发展做好准备。
让我们惊讶的发现通常都归结于我们不确定是否能保留这些原则的情况。
10点学习
1.TypeScript可以是JavaScript + Types
多年来,TypeScript团队一直在积极追求标准ECMAScript语法和运行时语义的采用和对齐。这就让TypeScript专注于在JavaScript之上提供一层类型语法和类型检查语义。职责明确划分:TypeScript = JavaScript + Types!
这是一个很棒的模型。这意味着编译器输出是人类可读的JavaScript,就像程序员写的那样。这使得调试生产代码变得很容易,即使您没有原始源代码。这意味着你不需要担心选择TypeScript会让你失去未来ECMAScript特性。它为运行时,甚至未来的JavaScript引擎打开了一扇门,它们可以忽略类型语法,从而在本地“运行”TypeScript。一个更简单的开发体验就在眼前!
在此过程中,TypeScript扩展了少量的特性,这些特性不太适合这个模型。枚举
,名称空间
,参数属性,实验修饰符它们的语义要求它们扩展成运行时代码,而这些运行时代码很可能永远不会被JavaScript引擎直接支持。
标准对齐❔
这没什么大不了的。的打字稿设计目标明确需要避免在将来引入更多的运行时特性。TypeScript团队的一个成员,Orta他制作了一张表情包幻灯片来强调这一点。

我们的工具链通过阻止它们的使用来解决这组不受欢迎的特性。这确保了我们不断增长的TypeScript代码库是真正的JS + Types。
标准对齐✔️
2.跟上编译器是值得的
打印稿的发展迅速.该语言的新版本引入了新的类型级特性,增加了对JavaScript特性的支持,提高了性能和稳定性,并改进了类型检查器以发现更多类型错误。所以使用新版本有很大的诱惑力!
在TypeScript努力保持兼容性的同时,这些类型检查的改进代表了构建过程的重大变化,因为新的错误在之前看似没有错误的代码库中被识别出来。因此,升级TypeScript需要一些干预才能获得这些好处。
还有另一种形式的兼容性需要考虑,即项目间兼容性。随着JavaScript和TypeScript语法的发展,声明文件需要包含新的语法。
如果一个库升级了TypeScript,并开始生成带有新语法的现代声明文件,那么使用该库的应用程序如果其版本的TypeScript不理解该语法,就会编译失败。新声明语法的一个例子是TypeScript 3.7中getter/setter访问器的emit.TypeScript 3.5或更早的版本无法理解这些内容。这意味着拥有一个使用不同编译器版本的项目生态系统并不理想。
☮️生态系统相干❔
在Bloomberg,代码库分布在使用通用工具的各种Git存储库中。尽管我们没有一个单一的注册表,但是我们有一个集中的TypeScript项目注册表。这让我们可以创建一个持续集成(CI)工作来“构建世界”,并在每个TypeScript项目上验证编译器升级的构建时和运行时效果。
这种全局检查功能非常强大。我们用它来评估TypeScript的Beta版和RC版,以便在常规版发布之前发现问题。拥有多样化的真实代码语料库意味着我们还能找到边缘情况。我们使用这个系统来指导在编译器升级之前对项目进行修复,这样升级本身就完美无缺了。到目前为止,这种策略效果很好,我们已经能够将整个代码库保持在最新版本的TypeScript上。这意味着我们不需要采用诸如底层的DTS文件.
☮️生态系统相干✔️
3.一致的tsconfig
设置是值得的
提供了很大的灵活性tsconfig
就是让TypeScript适应你的运行时平台。在所有项目都以相同的常青运行时为目标的环境中,对每个项目单独配置这一点是有风险的。
☮️生态系统相干❔
因此,我们让工具链负责生成tsconfig
在构建时使用“理想”设置。例如,“严格的”
Mode默认为启用,以增加类型安全性。“isolatedModules”
强制执行,以确保我们的代码可以通过一次操作一个文件的简单转译器快速编译。
治疗的另一个好处tsconfig
作为一个生成的文件,而不是作为一个源文件,它允许高级工具通过负责定义选项来灵活地链接多项目“工作区”,例如“引用”
和“路径”
.
这里存在一些紧张,因为少数项目希望能够进行定制,比如切换到更宽松的模式,以减少迁移负担。
最初,我们试图满足这些要求,并提供了少量的选项。后来我们发现,当使用一组选项构建的声明文件被使用不同选项的包使用时,这会导致包间冲突。这是一个例子。
可以创建由值指示的条件类型“strictNullChecks”
.
类型A = unknown extends {} ?字符串:数量;
如果“strictNullChecks”
,则A是A数量
.如果“strictNullChecks”
是禁用的,那么A是A字符串
.如果导出此类型的包没有使用与导入它的包相同的严格设置,则此代码将中断。
这是我们所面临的现实问题的一个简化例子。因此,我们选择放弃在严格模式上的灵活性,而倾向于为所有项目提供一致的配置。
☮️生态系统相干✔️
/ / tsconfig.json“路径”:{“lodash”:【“. . / . . /附件/ lodash”]}
这对于几乎所有的用例都非常有效。然而,我们发现这降低了生成的声明文件的质量。TypeScript编译器必须将合成导入语句注入到声明文件中,以允许复合类型——复合类型可以依赖于来自其他模块的类型。当合成导入依赖项中的引用类型时,我们发现“路径”
注入相对路径的方法(导入(“. . / . . /附件/ lodash”)
),而不是保留裸说明符(进口“lodash”
).对于我们的系统,外部包类型定义的相对位置是可能改变的实现细节,因此这是不可接受的。
☮️生态系统相干❔
我们找到的解决办法是使用环境模块:
/ / ambient-modules.d.ts声明模块“lodash”{出口*从“. . / . . /附件/ lodash”;出口默认的从“. . / . . /附件/ lodash”;}
环境模块是特殊的。TypeScript的declaration-emit保留了对它们的引用,而不是将它们转换为相对路径。
☮️生态系统相干✔️
5.重复数据删除类型可能很重要
应用程序的性能是至关重要的,所以我们试图最小化应用程序在运行时加载的JS量。我们的平台确保在运行时只使用包的一个版本。这种版本的重复删除意味着给定的包不能“冻结”或“固定”它们的依赖项。因此,这意味着包必须随着时间的推移保持兼容性。
我们希望为类型提供相同的“完全相同”保证,以确保对于给定的项目编译,类型检查只考虑包依赖项的一个版本。除了提高编译时的效率,其动机还在于确保类型检查的世界更好地反映了运行时的世界。我们特别想要避免过时问题和“名义地狱”,即通过“菱形模式”导入两个不兼容的名义类型版本。随着生态系统采用名义类型的增加,这种危险可能会增加。
⚖️可伸缩性❔
☮️生态系统相干❔
我们编写了一个确定性解析器,它根据正在构建的包的声明版本约束,精确地选择每个依赖项的一个版本。
⚖️可伸缩性✔️
☮️生态系统相干✔️
这意味着类型依赖关系图是动态组装的——它不是冻结的。虽然这种无固定依赖的方法提供了好处,并避免了一些危险,但我们后来了解到,由于TypeScript编译器中的微妙行为,它可能会引入另一种危险。看到9.生成的声明可以从依赖项中内联类型要学习更多的知识。
这些权衡和选择并不局限于我们的平台。它们同样适用于任何发布到definelytyped /npm的包,并由package.json中表示的每个包的版本约束的聚合效果决定“依赖”
.
6.应该避免隐式类型依赖关系
在TypeScript中引入全局类型很容易。更容易依赖全局类型。如果不选中,这意味着远程包之间可能发生隐藏耦合。TypeScript手册将其称为being“有点危险。”
⚖️可伸缩性❔
☮️生态系统相干❔
//注入全局类型的声明声明全球{接口字符串{fancyFormat(选择?:StringFormatOptions): string;}}//在文件的某处,在很远很远的地方…String.fancyFormat ();/ /没有错误!
这个问题的解决方案是众所周知的:比起全局状态,更喜欢显式依赖。TypeScript支持ECMAScript进口
和出口
长时间的陈述,实现了这个目标。
因此,剩下的唯一需要就是防止意外创建全局类型。值得庆幸的是,可以静态检测TypeScript允许引入全局类型的每一种情况。因此,我们能够更新我们的工具链,在使用这些工具的情况下检测和错误。这意味着我们可以放心地依赖这样一个事实:导入包的类型是一个无副作用的操作。
⚖️可伸缩性✔️
☮️生态系统相干✔️
7.申报文件有三种导出模式
并非所有的声明文件都是相同的。声明文件操作于三种模式之一,视内容而定;特别是进口
和出口
关键词。
- 全球-没有使用的声明文件
进口
或出口
会被认为是全球.顶级声明是全局导出的。 - 模块- - - - - -至少包含一个的声明文件
出口
声明将被认为是一个模块。只有出口
导出声明并且没有定义全局变量。 - 隐含的出口,没有
出口
声明,但使用进口
将触发已定义但未记录的行为。这意味着顶级声明将按命名方式处理出口
没有定义声明和全局变量。
我们不使用第一种模式。我们的工具链阻止了全局声明文件(见上一节:6.应该避免隐式类型依赖关系).这意味着所有声明文件都使用ES模块语法。
⚖️可伸缩性✔️
生态系统相干✔️
标准对齐✔️
也许令人惊讶的是,我们发现有点诡异的第三种模式很有用。通过在环境声明文件的顶部添加单行自导入,可以防止它们污染全局名称空间:Import {} from "./
.这一行代码使得转换第三方声明变得很简单,例如lib.dom.d.ts
,是模块化的,避免了维护一个更复杂的分叉的需要。
打印稿团队不喜欢第三种模式吗,所以考虑在可能的情况下避免使用。
8.包的封装可以被违反
如前所述5.重复数据删除类型可能很重要),使用非固定依赖意味着我们的包不仅要保持运行时兼容性,而且还要保持类型兼容性。这是一个挑战,所以要使这种兼容性的保存切实可行,我们必须真正理解哪些类型是公开的,并且必须以这种方式加以约束。第一步是显式区分公共模块和私有模块。
Node最近以。的形式获得了这种能力的package.json“出口”
场.它通过显式列出可以从包外部访问的文件来定义封装边界。
今天,TypeScript还不知道包出口因此没有一个概念,即依赖关系中的哪些文件被认为是公共的,哪些不是。当TypeScript合成import语句到所发出的传递类型时,这就成了一个问题.d.ts
文件。这对我们来说是不能接受的.d.ts
文件引用其他包中的私有文件。这是一个出错的例子。
/ / index.ts进口boxMaker从“另一个包”出口常量盒= boxMaker ();
以上的来源可以导致tsc
发出下列不想要的声明。
/ / index.d.ts出口常量箱:进口(“另一个包/私人”).盒子
这很糟糕,因为“另一个包/私人”
不是该包的兼容性承诺的一部分,所以可能会被移动或重命名,而不会有SemVer的重大颠簸。今天的TypeScript无法知道它生成了一个脆弱的导入。
☮️生态系统相干❔
我们通过两个步骤来缓解这个问题:
1.我们的工具链告诉TypeScript解析器指向依赖项(例如,“lodash / public1”
,“lodash / public2”
).我们通过在TypeScript文件流进编译器之前,在文件底部默默地添加只类型导入语句,来确保TypeScript知道所有合法的依赖入口点。
//用户的源代码//工具链注入,以辅助声明发出进口类型*作为__fake_name_1从“lodash / public1”;进口类型*作为__fake_name_2从“lodash / public2”;
当生成对推断传递类型的引用时,TypeScript的声明emit会更喜欢使用这些现有的命名空间标识符,而不是合成对私有文件的导入。
2.如果TypeScript生成了一个我们知道是私有的依赖项中的文件路径,我们的工具链就会产生错误。这类似于现有的TypeScript错误,当TypeScript意识到它正在生成一个潜在的危险路径到某个依赖时,就会发出错误。
错误TS2742:的推断类型'...'没有引用不能命名'...'.这可能是不可移植的.类型注释是必要的.
这通知用户通过显式地注释他们的导出来解决这个问题。或者,在某些情况下,他们需要通过直接从公共包入口点导出内部类型来更新依赖项以公开内部类型。
☮️生态系统相干✔️
我们期待TypeScript获得对入口点的一流支持,这样就没有必要采取这样的解决方案了。
9.生成的声明可以从依赖项中内联类型
需要导出的包.d.ts
声明,以便用户可以使用它们。我们选择使用TypeScript宣言
选项来生成.d.ts
原始文件.ts
文件。虽然它可以手写和维护.d.ts
兄弟文件与常规代码放在一起是不太可取的,因为保持它们同步是一种危险。
TypeScript的声明emit大多数时候都工作得很好。我们发现的一个问题是有时候TypeScript会将依赖项中的类型内联到生成的类型中(#37151).这意味着类型定义被重新定位并可能被复制,而不是通过导入语句引用。使用结构类型,编译器不必确保类型是从一个定义站点引用的——复制这些类型也可以。
我们已经看到极端情况下这种复制将声明文件从7KB扩展到700KB。需要下载和解析的冗余代码相当多。
⚖️可伸缩性❔
包内的类型内联不是生态系统的问题,因为它在外部是不可见的。当类型跨包边界内联时就会出现问题,因为它将两个特定版本耦合在一起。在我们的非固定包系统中,包可以独立地发展。这意味着存在类型不兼容的风险,特别是类型过时的风险。
☮️生态系统相干❔
通过实验,我们发现了防止类型声明内联的潜在技术,比如:
- 更喜欢
接口
而不是类型
(接口没有内联)- 如果一个
接口
需要的声明不导出,TSC将拒绝内联类型,并将生成一个明确的错误(例如,TS4023:导出的变量已经或正在使用外部模块的名称,但无法命名。
) - 如果一个
类型
生成的声明所需要的没有导出,TSC将静默地内联类型 - 尼古拉斯Jamieson写道关于优先选择接口而不是类型的文章,包括ESlint规则
- 如果一个
- 使类型公称(公称类型,如
枚举
和类
私有成员没有内联) - 向导出添加类型注释
⚖️可伸缩性
☮️生态系统一致性
内联行为似乎没有被严格指定。这是构造声明文件的方式的副作用。因此,上述方法在未来可能不会起作用。我们希望这是可以在TypeScript中形式化的东西。在那之前,我们将依靠用户教育来减轻这种风险。
10.生成的声明可以包含非必要的依赖项
TypeScript声明文件的消费者通常只关心包的公共类型API。TypeScript declaration emit会为项目中的每个TypeScript文件生成一个声明文件。其中一些内容可能与用户无关,并可能公开私有的实现细节。这种行为可能会让TypeScript的新来者感到惊讶,他们希望类型化是公共API的一种表示,就像手写类型化一样绝对类型.
一个例子是生成的声明,包括仅用于内部测试的函数的类型.
⚖️可伸缩性❔
由于我们的包系统知道所有公共包入口点,我们的工具可以爬行可到达类型的图,以识别所有不需要公开的类型。这就是死亡类型消除(DTE),或者更准确地说,摇树。我们为此编写了一个工具——它执行的工作最少只有消除声明文件中的代码。它不重写或重定位代码——它不是一个捆绑器。这意味着发布的声明是typescript生成的声明的一个未更改的子集。
减少出版类型的数量有几个优点:
- 它减少了与其他包的耦合(有些包不从它们的依赖项中重新导出类型)
- 它通过防止完全私有类型泄漏来帮助封装
- 它减少了需要用户下载和解压缩的已发布声明文件的数量和大小
- 它减少了TypeScript编译器在进行类型检查时需要解析的代码量
“摇晃”可以产生戏剧性的效果。我们已经看到一些包,其中>90%的文件和>90%的类型行都可以删除。
⚖️可伸缩性✔️
有些选择有尖锐的棱角
我们发现了一些语义上的意外tsconfig
选项。
授权baseUrl
在tsconfig
4.0在打印稿。如果您想使用项目引用或“路径”
,则需要指定abaseUrl
.这有一个副作用,就是导致所有裸说明符导入都要相对于项目的根目录进行解析。
/ /打包/ main.ts进口“兄弟”//将自动完成并类型检查' package-a/sibling.js '是否存在
危险在于,如果你想引入任何形式的“路径”
,它带有额外的含义进口“兄弟姐妹”
会被TypeScript错误地解释为导入<项目根目录> / sibling.js
从您的源目录中。
标准对齐❔
为了解决这个问题,我们用了一个难以启齿的baseUrl
.使用空字符可以防止不必要的裸自动补全。我们不建议你在家尝试。
我们报道这个在TypeScript问题跟踪器上看到这一点我很激动Andrew已经为TypeScript 4.1解决了这个问题,这将使我们能够对空字符说再见!
标准对齐✔️
JSON模块意味着合成的默认导入
如果你想用“resolveJsonModules”
,您也需要启用“useSyntheticDefaultImports”
以便TypeScript将JSON模块视为默认导入。使用默认导入很可能成为Node和Web将来处理JSON模块的方式。
启用“useSyntheticDefaultImports”
有一个不幸的结果,即人为地允许从没有默认导出的常规ES模块中默认导入!这是一个只有在运行代码时才会发现的危险,它很快就会崩溃。
标准对齐❔
理想情况下,应该有一种导入JSON模块的方法,不涉及全局启用合成默认值。
伟大的地方
从工具的角度来看,我们从TypeScript中看到的一些特别好的东西是有价值的。
增量构建已经必不可少。对增量构建的API支持是对我们在TypeScript 3.6中的一个巨大的推动,它允许自定义工具链进行快速重构。在我们报道性能问题当结合增量
与noEmitOnError
,Sheetal让他们在TypeScript 4.0中甚至更快。
⚖️可伸缩性✔️
“isolatedModules”
对于确保我们能够快速执行独立(一进一出)转译至关重要。TypeScript团队修复了一系列问题来改进这个选项,包括:
⚖️可伸缩性✔️
☮️生态系统相干✔️
项目引用是提供无缝IDE体验的关键。我们极大地利用了它们,使得基于工作空间的多包开发像单项目开发一样灵活。多亏了Sheetal在美国,它们现在甚至更好,支持无文件“solution-style tsconfigs”。
⚖️可伸缩性✔️
类型只进口都非常有用。我们在任何地方都使用它们来安全地区分运行时导入和编译时导入。它们对于某些模式的使用是必不可少的“isolatedModules”
允许我们使用“importsNotUsedAsValues”:“错误”
最大的安全。多亏了安德鲁提供这个!
☮️生态系统相干✔️
标准对齐✔️
“useDefineForClassFields”
对于确保我们发出的ESNext代码不会被重写是很重要的,这保留了语言的JS + Types本质。这意味着我们可以原生地使用类字段。多亏了内森提供此功能并使迁移过程尽可能顺利。
标准对齐✔️
TypeScript中的特性交付是非常偶然的。每次当我们意识到我们需要一个功能时,我们经常会发现它已经在下一个版本中交付了。
结论
最终的结果是,TypeScript现在是我们应用平台的一流语言。把TypeScript和另一个运行时集成在一起,这表明语言和编译器似乎和JavaScript一样灵活——它们几乎可以在任何地方使用。

虽然我们一路上学到了很多,但没有什么是不可逾越的。当我们需要支持时,我们惊喜地发现社区和TypeScript团队本身的回应。使用共享开源技术的一个明显好处是,当您遇到问题时,您往往会发现您金宝搏网址并不孤单。当你找到答案的时候,你会得到分享的乐趣。
确认
非常感谢Thomas Chetwin, Robin Ricard, Kubilay Kahveci, Scott Whittaker, Daniel Rosenwasser, Nathan Shively-Sanders, Titian Dragomir和Maxwell Heiber的评论。感谢Orta的双斜杠代码格式。
香蕉和盒子的图形是由Max Duluc创建的。