JavaScript's great by itself. But if you want to scale up, to do large projects with large teams, everyone knows you need TypeScript. Amazingly, TypeScript doesn't change your JavaScript code all that much—it just checks for mistakes. And every new release of the compiler brings new notations and new safeguards that promise to make your code even better. But if you have thousands of source files, or thousands of TypeScript projects in TikTok's case, enabling these new checks can be a lot of work. You turn on a setting in tsconfig.json, and suddenly you might be looking at 5,000 build errors. That's too much to fix! What to do?
Exempting old code
When modernizing coding standards, people quickly realize that we care most about new code. We certainly want to enforce best practices for new work, whereas often there isn't reasonable justification to go picking through thousands of old files. We can fix the old code later, when a new feature gives us a reason to work on those files.
The easiest way to exempt old code is to simply ignore errors in entire files. However, when our team researched this, it wasn't as easy as it sounds. The compiler configuration (tsconfig.json) can apply different checks to different file paths, but in practice this is clumsy and requires managing a delicate dependency boundary between old and new file groups. A much more powerful approach is to invoke the compiler once for all files, using a "wrapper" tool that intercepts errors and filters them for old files. We found a project tsc-silent that basically does this, although it's missing some features and seems to be no longer actively developed.
The need for scopes
There's a deeper problem, though: If we exempt an entire file, engineers can continue adding arbitrary amounts of code to that file without following best practices. Even if an engineer is a good citizen, converting one file at a time can be too time consuming. Instead, exemptions should apply to narrower scopes such as a function body within a file.
The TypeScript compiler provides a // @ts-ignore
code comment that can suppress a single error, similar to // eslint-ignore-next-line
for ESLint. But for a large team, this leads to the "broken windows" effect, where engineers get used to seeing suppressions everywhere in the codebase. Nobody will raise an eyebrow for a merge request that adds suppressions to new code instead of old code.
Some other members of my team already went down this path with ESLint, which produced the eslint-bulk
tool. You can read about it in our earlier post A new ESLint feature for large codebases. For today we'll focus on two key principles from that project:
- The suppressions are stored in a separate machine-generated file. When enabling new settings for old projects, the
eslint-bulk
command will do a "bulk" update of the file automatically. Using a separate file prevents "broken windows" in the source code: people know they shouldn't be editing the bulk suppressions file unless they are doing migrations. - A "scopeId" identifies regions within a source file. The
eslint-bulk
tool introduced an identifier based on the syntax tree. For example, if you want to refer tomyMethod()
that is a member ofMyClass
, the scopeId might be.MyClass.myMethod
. Unlike approaches that track ranges of line numbers, the scopeId is stable when adding/removing chunks of code or even reordering functions.
Leveraging these ideas, we created a new tool ts-bulk-suppress
that applies the same principles for TypeScript bulk suppressions. We've tried to make the two tools as similar as possible, providing a consistent mental model for ESLint and TypeScript migrations.
Give it a try!
Here's a quick look at how to use ts-bulk-suppress
with your existing project:
- It's recommended to install the tool by running
pnpm install ts-bulk-suppress --dev
(instead ofpnpm
, you can substitutenpm
oryarn
according to your package manager preference). - As an example, let's assume that we just enabled
noImplicitAny
and are now facing thousands of errors. Run this command to automatically generate the .ts-bulk-suppressions.json file: - Make sure the file is tracked by Git:
# Create a bulk.config.json file with suppressions for all compiler errors
ts-bulk-suppress --gen-bulk-suppress
git add .ts-bulk-suppressions.json
git commit -m "Enabling bulk suppressions"
At this point, if you run the tsc
command (the official TypeScript compiler), you will still see thousands of errors. But if you instead run ts-bulk-suppress
, the suppressed errors will be hidden.
👉 For full documentation, check out our ts-bulk-suppress GitHub page.
Some advanced features
Here's a few other interesting features we included:
- You can add manual rules to .ts-bulk-suppressions.json. For example, suppose that
my-project/src/legacy-sdk
contains hundreds of files that we never intend to fix. Rather than tracking thousands of individual suppressions, you could add a manual rule like this:
my-project/.ts-bulk-suppressions.json
. . .
"patternSuppressors": [
{
// Ignore codes TS7006 for all files under that directory:
"pathRegExp": "/src/legacy-sdk/.*",
"codes": [7006]
}
]
. . .
node_modules
folder, as these files are typically not your team's responsibility. This is similar to skipLibCheck, but scoped to individual errors.How it went
Here are some results from our first round of migration:
Total migrated: | 51 | projects |
Teams that used the "patternSuppressors" feature: | 4 | projects |
Teams that enabled "ignoreExternalError": | 43 | projects |
Total TypeScript files in these projects: | 18,998 | files |
Files with at least one bulk suppression: | 4,017 | files |
Files that did NOT require any suppressions: | 78.7% |
This histogram highlights the top 10 most frequently suppressed errors during this migration:
Count | Code | TypeScript error |
10,051 | TS2339 | Property '{0}' does not exist on type '{1}'. |
3,366 | TS7006 | Parameter '{0}' implicitly has an '{1}' type. |
3,234 | TS2322 | Type '{0}' is not assignable to type '{1}'. |
1,840 | TS2345 | Argument of type '{0}' is not assignable to parameter of type '{1}'. |
1,348 | TS18046 | '{0}' is of type 'unknown'. |
1,415 | TS7053 | Element implicitly has an 'any' type because expression of type '{0}' can't be used to index type '{1}'. |
796 | TS2304 | Cannot find name '{0}'. |
719 | TS2307 | Cannot find module '{0}' or its corresponding type declarations. |
677 | TS6133 | '{0}' is declared but never used |
626 | TS7031 | Binding element '{0}' implicitly has an '{1}' type. |
Some of these errors sound nontrivial, but they are problems in the type system; the runtime code was previously working and continues to work without issues. The important result is that 78% of files can now move forward using best practices. The legacy issues can be fixed when (or if) new work is required for those files. Without a way to fix problems incrementally, we would not have been able to adopt the newer compiler configuration at all.
The road ahead
Although ts-bulk-suppress
enabled us to get more projects migrated to use our recommended checks, the story isn't over. We're planning more features such as integration with toolchains (that invoke tsc
via an API), watch mode, and also support for the VS Code language service. The problems solved by this tool are not unique to TikTok. Anyone trying to modernize a large TypeScript codebase will face these same challenges. If you find this tool useful, maybe you can contribute to its development! Or simply create a GitHub issue and tell us about your experience. That's all for today!



