Skip to main content
hhow09's Blog

Incremental Type Safety: Adopting Strict Mode in Large Projects

In my previous post, I discussed how type checking serves as a primary tool to catch errors at compile time.

This post explores how I helped my team to ensure type safety incrementally in a large project without disrupting feature development.

Why is type safety important? #

However, for a legacy codebase, enabling type checking with tsc often results in thousands of errors. Fixing them all at once is rarely feasible without disrupting feature development. Yet fixing them without actually enabling type checking cannot prevent new type errors from being introduced.

The Strategy: Incremental adoption #

For most teams, spending an entire sprint fixing type errors is not practical.

The goal is to move towards a strict type checking environment without a "big bang" refactor. I followed two principles:

  1. New code must be strict: Stop the broken window.
  2. Existing code is fixed as it's touched: Refactor legacy files during feature updates.

Initiatives in typescript repo #

I started my survey with the following issues in typescript repo:

They are either not adopted or still open.

After turning to other open source tools, I found the following the cleanest to use.

Fixing frontend project #

Our project uses webpack as the bundler. With fork-ts-checker-webpack-plugin, we can run type checking within the webpack build process (npm run build).

1. Progressive allowlist #

Start with an allowlist of files that are already fixed.

new ForkTsCheckerWebpackPlugin({
    files: ["src/path/to/safe/file.ts" , "src/path/to/safe-folder/**/*.ts"],
}),

At this point, the listed files are ensured to be type safe.

2. Track known errors #

At a certain point, most files are already fixed. We can then include all files in the checker and track known errors to catch remaining type errors through issue filtering.

const knowTSErrors = require("./knowTSErrors.json");
/// ...
new ForkTsCheckerWebpackPlugin({
    files: ["src/**/*.ts"],
    issue: {
        exclude: (issue) =>
            knowTSErrors.some((item) => {
                const codeMatches = issue.code === item.code
                const fileMatches = issue.file.includes(item.file)
                const severityMatches = issue.severity === item.severity
                return codeMatches && fileMatches && severityMatches
            }),
    },
}),

knowTSErrors.json.

[
    {
        "code": "TS2339",
        "severity": "error",
        "file": "src/path/to/error/file.ts"
    },
    {
        "code": "TS2564",
        "severity": "error",
        "file": "src/path/to/error/file.ts"
    },
]

At this point, the entire project except the listed files is ensured to be type safe.

The rest is fixing the remaining type errors.

Fixing general Node.js projects #

typescript-strict-plugin was created mainly for existing projects that want to incorporate typescript strict mode, but project is so big that refactoring everything would take ages.

{
  "compilerOptions": {
    ...
    "strict": false,
    "plugins": [
      {
        "name": "typescript-strict-plugin",
        "paths": [
          "./src",
          "/absolute/path/to/source/"
        ],
        "exclude": [
          "./src/tests",
          "./src/fileToExclude.ts"
        ],
        "excludePattern": [
          "**/*.spec.ts"
        ]
      }
    ]
  }
}

Just like the frontend strategy, start with an allowlist in the paths option.

Then add an exclude file list in the exclude or excludePattern option.

Alternatives #

Inside Figma: a case study on strict null checks #

This article shows how Figma adopted strict mode incrementally. They use standard tsc to run type checking. Since "compiling a TypeScript file requires compiling all its dependencies (imports)", they need to fix files in a specific order: from the leaves of the dependency tree. This requires additional work and is not practical to fix alongside feature development.

Output parsing #

With tsc-output-parser, tsc --strict --noEmit | tsc-output-parser can parse the output of tsc to extract type errors.

This approach enables type checking with tsc while maintaining a knownErrors.json to track and incrementally fix known errors.

// typecheck.js
const knownErrors = JSON.parse(fs.readFileSync(path.join(__dirname, "knownErrors.json"), "utf8"))

let hasErrors = false
try {
    execSync(`tsc --strict --noEmit`, { stdio: "pipe" })
} catch (error) {
    const output = error.stdout?.toString() || error.toString()
    const parsedOutput = parse(output)
    const remainingErrors = parsedOutput.filter((item) => {
        // filter out the known errors
        return !knownErrors.some((knownError) => knownError.file === item.value.path.value && knownError.code === item.value.tsError.value.errorString)
    })

    if (remainingErrors.length > 0) {
        throw new Error(`Type errors found!`)
    }
}

process.exit(hasErrors ? 1 : 0)

Stricter TypeScript compilation with Betterer #

Betterer is a test runner that helps make incremental improvements to your code! It is based upon Jest's snapshot testing, but with a twist...