10 Insights from Adopting TypeScript at Scale

by Rob Palmer, JavaScript Infrastructure & Tooling Lead at Bloomberg and Co-chair of TC39

A few years ago, Bloomberg Engineering decided to adopt TypeScript as a first-class supported language. This article shares some of the insights and lessons we learned during this journey.

The headline is that we found TypeScript to be a strong net positive! Please keep that in mind when reading about some of the surprising corners we explored. As engineers, we are naturally attracted to seeing, solving and sharing problems, even when we’re having a good time 😉

Our codebase + TypeScript. The fuse is lit...

Background

Bloomberg already had a colossal investment in JavaScript before TypeScript even existed – more than 50 million lines of JS code. Our main product is the Bloomberg Terminal, which contains more than 10,000 apps. The variety of apps is huge, ranging from the display of intensive real-time financial data and news to interactive trading solutions and many forms of messaging. Back in 2005, the company started migrating those apps from Fortran and C/C++ to server-side JavaScript, with client-side JavaScript arriving around 2012. Today, we have more than 2,000 software engineers at the company writing JavaScript.

The Bloomberg Terminal

Transitioning this scale of codebase from plain JavaScript to TypeScript is a big deal. So we worked hard to ensure there was a thoughtful process that would keep us aligned with standards and preserve our existing capabilities to evolve and deploy our code quickly and safely.

If you’ve ever been part of a technology migration in a large company, you may be used to heavy-handed project management being used to force progress from reluctant teams who would rather be working on new features. We found that adopting TypeScript was something altogether different. Engineers were self-starting conversions and championing the process! When we launched the beta version of our TypeScript platform support, more than 200 projects opted into TypeScript in the first year alone. Zero projects went back.

What makes this usage of TypeScript special?

In addition to scale, something that makes this integration of TypeScript unique is that we have our own JavaScript runtime environment. This means that, in addition to well-known JavaScript host environments, such as browsers and Node, we also embed the V8 engine and Chromium directly to create our own JavaScript platform. The upside of this situation is that we can offer a simple developer experience in which TypeScript is supported directly by our platform and package ecosystem. Ryan Dahl’s Deno pursues similar ideas by putting TypeScript compilation into the runtime, whereas we keep it in tooling that is versioned independently of the runtime. An interesting consequence is that we get to explore what it’s like to exercise the TypeScript compiler in a standalone JS environment that spans both client and server and that does not use Node-specific conventions (e.g., there is no node_modules directory).

Our platform supports an internal ecosystem of packages that uses a common tooling and publishing system. This allows us to encourage and enforce best practices, such as defaulting to TypeScript’s “strict mode,” as well as ensuring global invariants. For example, we guarantee that all published types are modular rather than global. It also means that engineers can focus on writing code rather than needing to figure out how to make TypeScript play nicely with a bundler or test framework. DevTools and error stacks use sourcemaps correctly. Tests can be written in TypeScript and code coverage is accurately expressed in terms of the original TypeScript code. It just works.

We aim for regular TypeScript files to be the single source of truth for our APIs, as opposed to maintaining handwritten declaration files. This means we have a lot of code leaning heavily on the TypeScript compiler’s automatic generation of .d.ts declaration files from TypeScript source code. So when declaration-emit is not ideal, we notice it, as you will see.

Principles

Let’s outline three key principles we’re striving for.

⚖️ Scalability: Development speed should be kept high as more packages adopt TypeScript. Time spent installing, compiling, and checking code should be minimized.

☮️ Ecosystem Coherence: Packages should work together. Upgrading dependencies should be pain-free.

📜 Standards Alignment: We want to stick with standards, such as ECMAScript, and be ready for where they might go next.

The discoveries that surprised us usually came down to cases where we weren’t sure if we could preserve these principles.

10 Learning Points


1. TypeScript can be JavaScript + Types

Over the years, the TypeScript team has actively pursued the adoption of and alignment with standard ECMAScript syntax and runtime semantics. This leaves TypeScript to concentrate on providing a layer of type syntax and type-checking semantics on top of JavaScript. The responsibilities are clearly separated: TypeScript = JavaScript + Types!

This is a wonderful model. It means that the compiler output is human-readable JavaScript, just like the programmer wrote. This makes debugging production code easy even if you don’t have the original source code. It means you do not need to worry that choosing TypeScript might cut you off from future ECMAScript features. It leaves the door open to runtimes, and maybe even future JavaScript engines, that can ignore the type syntax and therefore “run” TypeScript natively. A simpler developer experience is in sight!

Along the way, TypeScript was extended with a small number of features that don’t quite fit this model. enum, namespace, parameter properties, and experimental decorators all have semantics that require them to be expanded into runtime code that, in all likelihood, will never be directly supported by JavaScript engines.

📜 Standards Alignment ❔

This is not a big deal. The TypeScript Design Goals articulate the need to avoid introducing more runtime features in the future. One member of the TypeScript team, Orta, created a meme-slide to emphasize this recognition.

One member of the TypeScript team, Orta, created a meme-slide to emphasize the need to avoid introducing more runtime features in the future (as per the TypeScript Design Goals).

Our toolchain addresses this set of undesirable features by preventing their use. This ensures that our growing TypeScript codebase is truly JS + Types.

📜 Standards Alignment ✔️

2. Keeping up with the Compiler is worthwhile

TypeScript evolves rapidly. New versions of the language introduce new type-level features, add support for JavaScript features, improve performance and stability, as well as improve the type-checker to find more type errors. So there’s a lot of enticement to use new versions!

Whilst TypeScript strives to preserve compatibility, these type-checking improvements represent breaking changes to the build process as new errors are identified in codebases that previously appeared error-free. Upgrading TypeScript therefore requires some intervention to get these benefits.

There is another form of compatibility to consider, which is inter-project compatibility. As both JavaScript and TypeScript syntaxes evolve, declaration files need to contain new syntax.

If a library upgrades TypeScript and starts producing modern declaration files with new syntax, application projects using that library will fail to compile if their version of TypeScript does not understand that syntax. An example of new declaration syntax is the emit of getter/setter accessors in TypeScript 3.7. These are not understood by TypeScript 3.5 or earlier. This means that having an ecosystem of projects using different compiler versions is not ideal.

☮️ Ecosystem Coherence ❔

At Bloomberg, codebases are spread across various Git repositories that use common tooling. Despite not having a monorepo, we do have a centralized registry of TypeScript projects. This allowed us to create a Continuous Integration (CI) job to “build the world” and verify the build-time and run-time effects of compiler upgrades on every TypeScript project.

This global checking is very powerful. We use this to assess Beta and RC releases of TypeScript to discover issues ahead of general release. Having a diverse corpus of real-world code means we also find edge cases. We use this system to guide fix-ups to projects ahead of the compiler upgrade, so that the upgrade itself is flawless. So far, this strategy has worked well and we have been able to keep the entire codebase on the latest version of TypeScript. This means we have not needed to employ mitigations such as downlevelling DTS files.

☮️ Ecosystem Coherence ✔️

3. Consistent tsconfig settings are worthwhile

Much of the flexibility provided by tsconfig is to allow you to adapt TypeScript to your runtime platform. In an environment where all projects are targeting the same evergreen runtime, it turns out to be a hazard for each project to configure this separately.

☮️ Ecosystem Coherence ❔

Therefore we made our toolchain responsible for generating tsconfig at build time with “ideal” settings. For example, "strict" mode is enabled by default to increase type-safety. "isolatedModules" is enforced to ensure our code can be compiled quickly by simple transpilers that operate on a single file at a time.

A further benefit of treating tsconfig as a generated file, rather than as a source file, is that it permits higher-level tooling to flexibly link together multi-project “workspaces” by taking responsibility for defining options such as "references" and "paths".

There is some tension here, as a minority of projects wanted the ability to make customizations such as switching to looser modes to reduce the migration burden.

Initially we tried to cater to these requests and gave access to a small number of options. Later we found that this resulted in inter-package conflicts, when declaration files built using one set of options were consumed by a package using different options. Here’s one example.

It’s possible to create a conditional type that is directed by the value of "strictNullChecks".

type A = unknown extends {} ? string : number;

If "strictNullChecks" are enabled, then A is a number. If "strictNullChecks" are disabled, then A is a string. This code breaks if the package exporting this type is not using the same strictness settings as the package importing it.

This is a simplified example of a real-life issue we faced. As a result, we chose to deprecate the flexibility on strictness modes in favour of having consistent configs for all projects.

☮️ Ecosystem Coherence ✔️

4. How you specify the location of dependencies matters

We needed the ability to explicitly declare the location of our dependencies to TypeScript. This is because our ES module system does not rely on the Node file-system convention of finding dependencies by walking up a series of directories named node_modules.

We needed the ability to declare a mapping of bare-specifiers (e.g., "lodash") to directory locations on disk ("c:\dependencies\lodash"). This is similar to the problem that import maps attempt to solve for the Web. At first, we tried using the "paths" option in tsconfig.

// tsconfig.json
  "paths": {
    "lodash": [ "../../dependencies/lodash" ]
  }

This worked great for nearly all use cases. However, we discovered this degraded the quality of generated declaration files. The TypeScript compiler necessarily injects synthetic import statements into declaration files to allow for composite types – where types can depend on types from other modules. When the synthetic imports reference types in dependencies, we found the "paths" approach injected a relative path (import("../../dependencies/lodash")) rather than preserving the bare-specifier (import "lodash"). For our system, the relative location of external package typings is an implementation detail that may change, so this was unacceptable.

☮️ Ecosystem Coherence ❔

The solution we found was to use Ambient Modules:

// ambient-modules.d.ts
declare module "lodash" {
  export * from "../../dependencies/lodash";
  export default from "../../dependencies/lodash";
}

Ambient Modules are special. TypeScript’s declaration-emit preserves references to them rather than converting them to a relative path.

☮️ Ecosystem Coherence ✔️

5. De-duplicating types can be important

App performance is critical, so we try to minimize the volume of JS that apps load at runtime. Our platform ensures that only one version of a package is used at runtime. This de-duplication of versions means that a given package cannot “freeze” or “pin” their dependencies. Consequently, this means packages must preserve compatibility over time.

We wanted to provide the same “exactly-one” guarantee for types to ensure that, for a given compilation of a project, the type-check would only ever consider one single version of a package’s dependencies. In addition to compile-time efficiency, the motivation was to ensure the type-checked world better reflects the runtime world. We specifically wanted to avoid staleness issues and “nominality hell,” in which two incompatible versions of nominal types are imported via a “diamond pattern”. This is a hazard that will likely grow as ecosystem adoption of nominal types increases.

⚖️ Scalability ❔

☮️ Ecosystem Coherence ❔

We wrote a deterministic resolver that selects exactly one version of each dependency to type against based on the declared version constraints of the package being built.

⚖️ Scalability ✔️

☮️ Ecosystem Coherence ✔️

This means the graph of type dependencies is dynamically assembled – it is not frozen. Whilst this unpinned dependency approach provides benefits and avoids some hazards, we later learned that it can introduce a different hazard due to subtle behavior in the TypeScript compiler. See 9. Generated declarations can inline types from dependencies to learn more.

These trade-offs and choices are not specific to our platform. They apply equally to anyone publishing to DefinitelyTyped/npm, and are determined by the aggregate effect of each package’s version constraints expressed in package.json "dependencies".

6. Implicit Type Dependencies should be avoided

It’s easy to introduce global types in TypeScript. It’s even easier to depend on global types. If left unchecked, this means it is possible for hidden coupling to occur between distant packages. The TypeScript Handbook calls this out as being “somewhat dangerous.”

⚖️ Scalability ❔

☮️ Ecosystem Coherence ❔

// A declaration that injects global types
declare global {
  interface String {
    fancyFormat(opts?: StringFormatOptions): string;
  }
}

// Somewhere in a file far, far away...
String.fancyFormat();  // no error!

The solution to this is well-known: prefer explicit dependencies over global state. TypeScript has provided support for ECMAScript import and export statements for a long time, which achieve this goal.

So the only remaining need was to prevent accidental creation of global types. Thankfully, it is possible to statically detect each of the cases where TypeScript permits the introduction of global types. So, we were able to update our toolchain to detect and error in the cases where these are used. This means we can safely rely on the fact that importing a package’s types is a side-effect-free operation.

⚖️ Scalability ✔️

☮️ Ecosystem Coherence ✔️

7. Declaration files have three export modes 

Not all declaration files are equal. A declaration file operates in one of three modes, depending on the content; specifically the usage of import and export keywords.

  1. global — A declaration file with no usage of import or export will be considered to be global. Top-level declarations are globally exported.
  2. module — A declaration file with at least one export declaration will be considered to be a module. Only the export declarations are exported and no globals are defined.
  3. implicit exports — A declaration file that has no export declarations, but does use import will trigger defined-yet-undocumented behaviour. This means that top-level declarations are treated as named export declarations and no globals are defined.

We do not use the first mode. Our toolchain prevents global declaration files (see previous section: 6. Implicit Type Dependencies should be avoided). This means all declaration files use ES module syntax.

⚖️ Scalability ✔️

Ecosystem Coherence ✔️

📜 Standards Alignment ✔️

Perhaps surprisingly, we found the slightly-spooky third-mode to be useful. By adding just a single-line self-import to the top of ambient declaration files, you can prevent them from polluting the global namespace: import {} from "./<my-own-name>";. This one-liner made it trivial to convert third-party declarations, such as lib.dom.d.ts, to be modular and avoided the need to maintain a more complex fork.

The TypeScript team do not seem to love the third-mode, so consider avoiding it where possible.

8. Encapsulation of Packages can be violated

As explained earlier (in 5. De-duplicating types can be important), our use of unpinned dependencies means it is important for our packages to preserve not only runtime-compatibility, but also type-compatibility over time. That’s a challenge, so to make this preservation of compatibility practical, we have to really understand which types are exposed and must be constrained in this way. A first step is to explicitly differentiate public vs. private modules.

Node recently gained this capability in the form of the package.json "exports" field. This defines an encapsulation boundary by explicitly listing the files that are accessible from outside the package.

Today, TypeScript is not aware of package exports and so does not have the concept of which files within a dependency are considered public or not. This becomes a problem during declaration generation, when TypeScript synthesizes import statements to transitive types in the emitted .d.ts file. It is not acceptable for our .d.ts files to reference private files in other packages. Here’s an example of it going wrong.

// index.ts
import boxMaker from "another-package"
export const box = boxMaker();

The above source can lead to tsc emitting the following undesirable declaration.

// index.d.ts
export const box : import("another-package/private").Box

This is bad because "another-package/private" is not part of that package’s compatibility promise, so might be moved or renamed without a SemVer major bump. TypeScript today has no way of knowing it generated a fragile import.

☮️ Ecosystem Coherence ❔

We mitigate this problem using two steps:

1. Our toolchain informs the TypeScript resolver of the intentionally-public bare-specifier paths that point to dependencies (e.g., "lodash/public1", "lodash/public2"). We ensure TypeScript knows about the full set of legitimate dependency entry-points by silently adding type-only import statements to the bottom of the TypeScript files just before they flow into the compiler.

// user's source code

// injected by toolchain to assist declaration emit
import type * as __fake_name_1 from "lodash/public1";
import type * as __fake_name_2 from "lodash/public2";

When generating references to inferred transitive types, TypeScript’s declaration emit will prefer to use these existing namespace identifiers rather than synthesizing imports to private files.

2. Our toolchain generates errors if TypeScript generates a path to a file in a dependency that we know is private. This is analogous to the existing TypeScript errors emitted when TypeScript realizes that it is generating a potentially hazardous path to a dependency.

error TS2742: The inferred type of '...' cannot be named without a reference to '...'.
This is likely not portable. A type annotation is necessary.

This informs the user to work around the issue, by explicitly annotating their exports. Or, in some cases, they need to update the dependency to publicise internal types by directly exporting them from a public package entrypoint.

☮️ Ecosystem Coherence ✔️

We look forward to TypeScript gaining first-class support for entrypoints so that workarounds like this are unnecessary.

9. Generated declarations can inline types from dependencies

Packages need to export .d.ts declarations so that users can consume them. We choose to use the TypeScript declaration option to generate .d.ts files from the original .ts files. Whilst it’s possible to hand-write and maintain .d.ts sibling files alongside regular code, this is less preferable because it is a hazard to keep them synchronized.

TypeScript’s declaration emit works well most of the time. One issue we found was that sometimes TypeScript will inline types from a dependency into the generated types (#37151). This means the type definition is relocated and potentially duplicated, rather than being referenced via an import statement. With structural typing, the compiler is not compelled to ensure types are referenced from one definition site – duplication of these types can be ok.

We have seen extreme cases where this duplication has inflated declaration files from 7KB to 700KB. That’s quite a lot of redundant code to download and parse.

 ⚖️ Scalability ❔

Inlining of types within a package is not an ecosystem problem, because it is not externally visible. It becomes problematic when types are inlined across package boundaries, because it couples those two specific versions together. In our unpinned package system, packages can evolve independently. This means there is a risk of type incompatibility and, in particular, a risk of type staleness.

☮️ Ecosystem Coherence ❔

Through experimentation, we discovered potential techniques to prevent inlining of type declarations, such as:

⚖️ Scalability 🤔

 ☮️ Ecosystem Coherence 🤔

The inlining behaviour does not seem to be strictly specified. It is a side-effect of the way declaration files are constructed. So, the above methods may not work in future. We hope this is something that can be formalized in TypeScript. Until then we shall rely on user education to mitigate this risk.

10. Generated Declarations can contain non-essential dependencies

Consumers of TypeScript declaration files typically only care about the public type API of a package. TypeScript declaration emit generates exactly one declaration file for every TypeScript file in a project. Some of this content can be irrelevant to users and can expose private implementation details. This behavior can be surprising to newcomers to TypeScript, who expect the typings to be a representation of the public API like the handwritten typings found on Definitely Typed.

One example of this is generated declarations including typings for functions used only for internal testing.

 ⚖️ Scalability ❔

Since our package system knows all the public package entrypoints, our tooling can crawl the graph of reachable types to identify all the types that do not need to be made public. This is Dead Type Elimination (DTE) or more precisely, Tree-Shaking. We wrote a tool to do this – it performs minimal work by only eliminating code from declaration files. It does not rewrite or relocate code – it is not a bundler. This means the published declarations are an unchanged subset of the TypeScript-generated declarations.

Reducing the volume of published types has several advantages:

  • it decreases the coupling to other packages (some packages do not re-export types from their dependencies)
  • it aids encapsulation by preventing fully-private types from leaking
  • it decreases the count and size of the published declaration files that need to be downloaded and unpacked by users
  • it decreases the volume of code the TypeScript compiler has to parse when type-checking

The “shaking” can have a dramatic effect. We’ve seen several packages where >90% of the files and >90% of the lines of types can be dropped.

 ⚖️ Scalability ✔️

Some options have sharp edges

We found a few surprises in the semantics of some of the tsconfig options.

Mandated baseUrl in tsconfig

In TypeScript 4.0. if you want to use Project References or "paths", you are required to also specify a baseUrl. This has the side effect of causing all bare-specifier imports to resolve relative to your project’s root directory.

// package-a/main.ts
import "sibling"   // Will auto-complete and type-check if `package-a/sibling.js` exists

The hazard is that if you want to introduce any form of "paths", it carries the additional implication that import "sibling" will be undesirably interpreted by TypeScript as an import of <project-root>/sibling.js from inside your source directory.

📜 Standards Alignment ❔

To work around this, we used an unspeakable baseUrl. Using a null character prevents the undesirable bare auto-completions. We don’t recommend you try this at home.

We reported this on the TypeScript issue tracker and were thrilled to see that Andrew has solved this for TypeScript 4.1, which will enable us to say goodbye to the null character!

📜 Standards Alignment ✔️

JSON modules imply synthetic default imports

If you want to use "resolveJsonModules", you are required to also enable "useSyntheticDefaultImports" in order for TypeScript to see the JSON module as a default import. Using default imports is likely to become the way that Node and the Web handle JSON modules in future.

Enabling "useSyntheticDefaultImports" has the unfortunate consequence of artificially allowing default imports from regular ES modules that do not have a default export! This is a hazard that you will only find out about when you come to run the code and it quickly falls over.

📜 Standards Alignment ❔

Ideally, there should be a way to import JSON modules that does not involve globally enabling synthetic defaults.

The Great Parts

It’s worth calling out some of the particularly good things we’ve seen from TypeScript along the way from a tooling perspective.

Incremental builds have been essential. API support for incremental builds was a huge boost to us in TypeScript 3.6, allowing custom toolchains to have fast rebuilds. After we reported a performance issue when combining incremental with noEmitOnError, Sheetal made them even faster in TypeScript 4.0.

 ⚖️ Scalability ✔️

"isolatedModules" was vital to ensure we can perform fast standalone (one in, one out) transpilation. The TypeScript team fixed a bunch of issues to improve this option including:

 ⚖️ Scalability ✔️

☮️ Ecosystem Coherence ✔️

Project References are the key to providing a seamless IDE experience. We leverage them greatly to make multi-package workspace-based development as slick as single-project development. Thanks to Sheetal, they are now even better and support file-less “solution-style” tsconfigs.

 ⚖️ Scalability ✔️

Type-only imports have been super useful. We use them everywhere to safely distinguish runtime imports from compile-time imports. They are essential for certain patterns using "isolatedModules" and allowed us to use "importsNotUsedAsValues": "error" for maximum safety. Thanks to Andrew for delivering this!

☮️ Ecosystem Coherence ✔️

📜 Standards Alignment ✔️

"useDefineForClassFields" was important for ensuring our emitted ESNext code does not get rewritten, preserving the JS + Types nature of the language. It means we can natively use Class Fields. Thanks to Nathan for providing this and making the migration process as smooth as possible.

📜 Standards Alignment ✔️

Feature delivery in TypeScript has been very fortuitous. Each time we realized we needed a feature, we frequently discovered it was already being delivered in the next version.

Conclusion

The end result is that TypeScript is now a first-class language for our application platform. Integrating TypeScript with yet-another-runtime shows that the language and compiler seems to be just as flexible as JavaScript – they can both be used pretty much anywhere.

A JavaScript box exploding to reveal TypeScript inside.

Whilst we had to learn a lot along the way, nothing was insurmountable. When we needed support, we were pleasantly surprised at the responses from both the community and the TypeScript team themselves. A clear benefit of using shared open source technology is that when you have a problem, more often than not you find you are not alone. And when you find answers, you get the joy of sharing them.

Acknowledgements

Many thanks for the reviews from Thomas Chetwin, Robin Ricard, Kubilay Kahveci, Scott Whittaker, Daniel Rosenwasser, Nathan Shively-Sanders, Titian Dragomir, and Maxwell Heiber. And thanks to Orta for the Twoslash code-formatting.

The Banana & Box graphics were created by Max Duluc.