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
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:
allowJslets .js coexist with .ts/.tsx.checkJscan be enabled later to get type hints from JSDoc'd files.incrementalspeeds up repeated type checks; good for CI caching.skipLibCheckreduces 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:
- Rename
foo.js→foo.ts(or.tsxfor JSX). - Fix obvious syntactic errors (exports/imports).
- Add lightweight types: function args and return types for public APIs.
- Run
tsc --noEmitand 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
strictNullChecksin targeted packages. - Phase 3: aim for
strict: trueper-package as coverage reaches threshold.
Typing untyped third-party libraries
Preferred options:
- Install
@types/foofrom 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.tsthat 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:
- Run
tsc --noEmiton a single package or targeted path in CI. - When green, expand the scope to multiple packages.
- 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
anyand@ts-ignoreusages - 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.jsonwithallowJs:true. - Enable ESLint with
@typescript-eslintand 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
@typesortypesin 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
anyand@ts-ignoreas 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
Post a Comment