From Chaos to Types: A Step-by-Step Incremental TypeScript Migration for Legacy JavaScript Apps

Hook: You have a sprawling legacy JavaScript codebase, bugs sneaking in at runtime, and new team members who spend days understanding loosely-typed modules. TypeScript promises safety and developer productivity—but a full rewrite is a non-starter. This guide shows you how to perform an incremental TypeScript migration that minimizes risk, delivers early wins, and can be measured like a product.

Why migrate incrementally

Business & engineering benefits

Incremental TypeScript migration delivers both tactical and strategic wins:

  • Reduce runtime bugs by catching type errors at compile time.
  • Improve developer onboarding—types are documentation that IDEs use.
  • Make refactors safer and faster; fewer regressions means faster feature delivery.
  • Lower long-term maintenance costs; teams spend less time debugging subtle API mismatches.

Framing the migration as a program with measurable KPIs (type coverage, PR velocity, defects) makes it easier to gain stakeholder buy-in. If you need an executive summary, prepare a simple ROI table with expected hours saved per bug class and projected reduction in customer-facing incidents.

Avoid the big-bang rewrite trap

Large rewrites fail because they:

  • Take too long and block feature work.
  • Introduce regressions that escape the old test surface.
  • Cause huge PRs that are hard to review—team friction kills momentum.

An incremental approach lets you validate assumptions, instrument metrics, and pivot if needed.

Preparation and planning

Audit: what to measure first

Start with an audit. Capture:

  • Number of JS files and their sizes.
  • Module dependency graph (who imports which file).
  • Business criticality: user-facing vs internal services.
  • Test coverage per file/package.
  • Third-party untyped dependencies.

Tools: madge or depcruise for graphs, custom scripts to count files and import frequency, and test coverage reports.

Prioritization: risk-driven approach

Prioritize by impact and coupling. Good early targets:

  • Low-coupling utility modules—quick wins with small PRs.
  • Public APIs and SDKs—improves consumer experience quickly.
  • High-bug modules—add types to prevent repeat issues.

Create a matrix: priority = business_impact * change_risk * coupling. Focus on high-impact, low-coupling components first to build confidence.

Setting goals and pacing the migration

Set realistic cycles: e.g., two-week sprints with 3–5 small conversion PRs each. Track progress with KPIs such as files converted/week, type-check failures resolved, and CI check pass rate. Plan for training sessions and a style guide that defines acceptable uses of any and patterns for types.

Practical config and tooling

Supporting Image 1 — Close-up split-screen code editor 'before and after' view: left half shows mess

Key tsconfig flags for mixed JS/TS repos

Use a root tsconfig.json that allows JavaScript while you migrate files:

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "commonjs",
    "allowJs": true,
    "checkJs": false,
    "declaration": true,
    "emitDeclarationOnly": false,
    "outDir": "dist",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "incremental": true,
    "tsBuildInfoFile": ".tsbuildinfo"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Notes:

  • allowJs lets .js coexist with .ts/.tsx.
  • checkJs can be enabled later to get type hints from JSDoc'd files.
  • incremental speeds up repeated type checks; good for CI caching.
  • skipLibCheck reduces noise from vendor types during migration.

ESLint, editor and build integration

Use ESLint with @typescript-eslint to maintain quality across JS & TS. Example minimal .eslintrc:

{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "overrides": [
    {
      "files": ["*.ts", "*.tsx"],
      "rules": {
        "@typescript-eslint/explicit-module-boundary-types": "off"
      }
    },
    {
      "files": ["*.js"],
      "rules": {
        "no-undef": "off"
      }
    }
  ]
}

Editor settings: recommend the team to use TypeScript-aware editors (VS Code). Configure typescript.tsdk to lock the version, and enable type checking on save gradually.

Project references and performance tips

For very large repos, adopt TypeScript project references to split the monolith into typed units. This improves incremental build times and enables parallelism. Use tsc --build --incremental in CI and cache .tsbuildinfo artifacts.

Gradual typing strategies

Declaration files and .d.ts patterns

Create types folder and internal d.ts definitions for untyped packages you can’t (yet) replace. Example src/types/custom.d.ts:

declare module 'legacy-untyped' {
  export function legacyDo(x: any): any;
}

Keep these stubs minimal and track them as technical debt to be replaced by better types over time.

Module-by-module conversion pattern

Convert files one at a time:

  1. Rename foo.jsfoo.ts (or .tsx for JSX).
  2. Fix obvious syntactic errors (exports/imports).
  3. Add lightweight types: function args and return types for public APIs.
  4. Run tsc --noEmit and address errors iteratively.

Keep PRs small—each PR should convert 5–20 files max. Use branch naming like feat/ts-migrate-utilities and a PR template that includes what rules or bypasses were added.

Shortcuts: any, @ts-ignore and staged strictness

Accept pragmatic escape hatches: any and @ts-ignore are useful temporarily. Track their usage with linter rules (e.g., warn but don’t error) and create a schedule to remove them. Implement an incremental strictness ladder:

  • Phase 0: allowJs: true, checkJs: false.
  • Phase 1: convert core modules, enable noImplicitAny—soft warnings.
  • Phase 2: enable strictNullChecks in targeted packages.
  • Phase 3: aim for strict: true per-package as coverage reaches threshold.

Typing untyped third-party libraries

Preferred options:

  • Install @types/foo from DefinitelyTyped when available.
  • Write lightweight internal .d.ts stubs for narrow parts you use.
  • Contribute types upstream for commonly used modules.
  • Or wrap usage in a typed façade: myWrapper.ts that isolates untyped code.

Testing and quality gates

Adding TypeScript checks to CI gradually

Don’t flip the switch in CI all at once. Staged approach:

  1. Run tsc --noEmit on a single package or targeted path in CI.
  2. When green, expand the scope to multiple packages.
  3. Enforce type-checking on merge only after sufficient churn has been addressed.

Sample GitHub Actions job to run a scoped type-check:

name: Type Check Scoped
on: [pull_request]
jobs:
  tsc:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with: {node-version: '18'}
      - run: npm ci
      - run: npx tsc --noEmit -p packages/core/tsconfig.json

Metrics to track migration progress

Track these KPIs weekly:

  • Files converted to .ts/.tsx
  • Type coverage (percentage of exported APIs typed)
  • Number of any and @ts-ignore usages
  • CI type-check failure rate on PRs
  • Defects (pre/post) for migrated modules

Visualize trends in a dashboard (Grafana/BigQuery) so stakeholders can see progress.

Common gotchas and how to resolve them

Runtime vs compile-time failures

TypeScript prevents classes of errors at compile time, but incorrect runtime assumptions remain a risk. Always keep a strong test suite. When you convert a module, add smoke tests that exercise the runtime behavior. If a type conversion changes emitted JS (e.g., default exports vs CommonJS), add a compatibility wrapper:

// cjs-wrapper.js
module.exports = require('./foo').default || require('./foo');

Complex typing and incremental strictness

For complex generic types, prefer small, well-named type aliases and move complexity into isolated modules. If a type is blocking conversion, temporarily annotate with unknown + runtime guards rather than any, then file a debt ticket to refine it.

Monorepos and package boundary strategies

In monorepos use per-package tsconfig.json with composite and project references. Migrate leaf packages first, then shared libraries. Use automated scripts to update internal import paths and package.json types fields as you add typings.

Babel, SWC and interop notes

If you use Babel/SWC for transpilation, you can still use TypeScript for type checking by running tsc --noEmit in CI. Beware: Babel won't emit type declarations—use tsc --emitDeclarationOnly for packages that need .d.ts files.

Migration checklist, scripts and a lightweight case study

Step-by-step migration checklist

  • Audit repository and generate dependency graph.
  • Pick pilot modules (low coupling, high visibility).
  • Add TypeScript dev deps and base tsconfig.json with allowJs:true.
  • Enable ESLint with @typescript-eslint and agree on a types style guide.
  • Convert files module-by-module with small PRs.
  • Add scoped type-checks to CI and expand scope over time.
  • Implement project references if build times grow.
  • Gradually increase strictness flags per package using a ladder.
  • Retire internal stubs and publish proper @types or types in package.json.
  • Measure and communicate KPIs weekly; refine backlog for remaining debt.

Reusable scripts and codemod examples

Small scripts speed up repetitive steps. Example: rename .js to .ts for utilities (use carefully):

# rename-js-to-ts.sh
find src -name "*.js" -not -path "./node_modules/*" -print0 \
  | xargs -0 -I {} sh -c 'mv "$1" "${1%.js}.ts"' -- {}

Use jscodeshift for AST-safe transforms like converting CommonJS to ES modules:

npx jscodeshift -t transforms/cjs-to-esm.js src --extensions=js,ts

Example small codemod behavior: convert module.exports = to export default where safe. Test codemods on a branch and code-review their diffs carefully.

Before / after code examples

Before (JS):

// src/math.js
function add(a, b) {
  return a + b;
}
module.exports = { add };

After (TS):

// src/math.ts
export function add(a: number, b: number): number {
  return a + b;
}

Lightweight case study: AcmeApp

Summary: AcmeApp (1.2k JS files, monorepo with 8 packages) migrated incrementally over 6 months.

  • Team: 6 engineers, part-time migration (10–15% allocation).
  • Approach: pilot on utilities + API client, then core business packages.
  • Automation: 4 codemods, rename script, CI scoped type-checks expanded weekly.
  • Outcomes: 62% of packages typed, 45% reduction in production type-related incidents, 20% faster onboarding time estimated.
  • Cost: ~480 engineer-hours over 6 months (part of normal sprint capacity).

Lessons learned:

  • Small PRs and weekly demos maintained momentum.
  • Invest time upfront in a few codemods—saved hours per conversion later.
  • Track any and @ts-ignore as debt and treat them as review blockers after month 3.

Conclusion

Incremental TypeScript migration is a practical, low-risk path from chaotic legacy JavaScript to a safer, more maintainable codebase. Treat the migration as a product: define goals, measure progress, automate repetitive steps, and communicate wins. Start small, automate where it pays off, and raise strictness gradually.

Ready to begin? Use the checklist above and try converting a small utility module this week. If you want a template tsconfig.json, codemod examples, or a PR template to standardize conversions across your team, grab the resources linked below.

related topic — migration templates and scripts

Have questions about a specific stack (React, Next.js, Node, monorepo)? Reply with details about your repository and I’ll suggest an actionable next step tailored to your setup.

Call-to-action: Pick one file to migrate today. Make a small PR, run tsc --noEmit, and measure the result. Share your experience and we’ll iterate on the plan together.


Comments

Popular posts from this blog

Graph Visualization using MSAGL with Examples

Practical Example To Visualize Entities In Live Application Using MSAGL

How to Count the Number of Times a Statement Executes Using Visual Studio Breakpoint Conditions