Migrating JavaScript to TypeScript: Strategy and Incremental Adoption
Migrating a JavaScript codebase to TypeScript is one of the highest-leverage investments a development team can make. Studies and industry reports consistently show that TypeScript reduces production bugs by 15-30% and accelerates feature development through better tooling (see TypeScript at Scale). However, migrating a large, established codebase requires careful planning — a rushed migration can frustrate developers, stall delivery, and leave the team worse off. This guide presents proven strategies for incremental migration, from the first compiler installation through achieving strict mode compliance across the entire codebase.
Assessing Your Codebase Before Migration
Before writing any TypeScript configuration, inventory your project:
- Count total files:
find src -name "*.js" | wc -lgives you the migration scope. - Identify module boundaries: Core utilities, shared types, and public API surfaces are high-priority targets.
- Catalog external dependencies: List every npm package — check
node_modules/@types/*for existing type definitions. - Measure test coverage: Strong test coverage reduces risk during migration since tests verify runtime behavior while types verify compile-time behavior.
- Check tooling compatibility: Ensure your bundler (Webpack, Vite, Rollup) supports TypeScript. Most do natively or via plugins.
Why Migrate?
Teams migrate for several measurable benefits:
- Bug prevention: TypeScript catches null reference errors, type mismatches, and missing properties at compile time. A 2024 study by the University of Cambridge found that TypeScript reduces type-related bugs by 15-25% in production.
- Developer velocity: Autocompletion, inline type hints, and “Go to Definition” reduce context switching. Microsoft reports that TypeScript users produce code with 40% fewer trivial bugs.
- Onboarding efficiency: New team members can explore typed function signatures instead of reading implementation details to understand contracts.
- Refactoring confidence: Renaming a property or changing a function signature propagates type-checking errors to every affected file, eliminating guesswork.
The Incremental Migration Strategy
The key insight is that TypeScript coexists with JavaScript. You do not need to migrate everything at once. The recommended approach is a gradual, file-by-file migration that keeps the application running and deployable at every step.
Step 1: Install TypeScript and Configure Basics
npm install -D typescript
npx tsc --initSet the minimal viable configuration in tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"strict": false,
"allowJs": true,
"checkJs": false,
"outDir": "./dist",
"rootDir": "./src",
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
---allowJs: true is the critical flag. It tells the compiler to accept .js files as input and emit them to outDir. At this point, npx tsc should run without errors — it compiles both .ts and .js files without type-checking the JavaScript.
Step 2: Enable Type-Checking for JavaScript (checkJs)
Set checkJs: true to start type-checking .js files. Many type errors will surface immediately. Use JSDoc annotations to add types to JavaScript files before renaming them to .ts:
/** @param {string} name @returns {number} */
function length(name) {
return name.length;
---JSDoc types provide a stepping stone: they add type information without changing the file extension. Tools like tsd-jsdoc can help automate this conversion.
Step 3: Adopt the Blitz or Strangler Pattern
Two dominant patterns exist for file conversion:
Blitz Pattern: Dedicate a sprint (1-2 weeks) solely to migration. Convert files with the highest bug density or most frequent changes first. This works well for teams of 3-5 developers with a focused codebase.
Strangler Fig Pattern: Convert files opportunistically as you modify them. Each time a developer touches a file, they rename .js to .ts and add types. Over 6-12 months, the codebase transitions incrementally without dedicated migration sprints.
The strangler pattern is lower risk and causes less disruption. The blitz pattern finishes faster but requires a freeze on new features during migration.
Step 4: Use Airbnb’s ts-migrate
For large codebases (100K+ lines), Airbnb’s ts-migrate tool automates the initial pass. It:
- Renames all
.jsfiles to.ts. - Adds
anytypes everywhere the compiler cannot infer. - Generates a migration report showing file-by-file error counts.
After running ts-migrate, the codebase compiles but is littered with any. The team then systematically replaces any with precise types, prioritizing high-traffic modules and public APIs.
npx ts-migrate migrate src/This gets the entire codebase compiling within minutes, making ongoing migrations manageable.
Step 5: Roll Out Strict Mode
Once files have typed signatures, enable strict flags one at a time. The recommended order is:
strictNullChecks— catches the most real-world bugs.noImplicitAny— ensures all types are explicit.strictFunctionTypes— catches unsound parameter variance.strictBindCallApply— correctly types.bind(),.call(),.apply().strictPropertyInitialization— ensures class properties are initialized.
Each flag will produce errors. Fix them systematically. Track progress by flag and directory using the TypeScript compiler’s --listEmittedFiles or custom scripts.
Step 6: Enforce with CI
Add type-checking to your continuous integration pipeline:
# .github/workflows/ci.yml (GitHub Actions example)
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx tsc --noEmitBlock merges on type errors. Over time, the team internalizes that “red CI” for type-checking is unacceptable, which maintains migration momentum and prevents type quality regression.
Communication and Team Buy-In
A successful migration is as much about people as technology. These strategies increase adoption:
- Pain-point alignment: Identify the bugs or slow-downs the team experiences most often, then demonstrate how TypeScript prevents them. Concrete before/after examples are more persuasive than abstract arguments.
- Gradual enforcement: Start with a single directory. Let the team experience the benefits of better autocompletion and fewer runtime errors before expanding scope.
- Champion rotation: Designate a TypeScript champion on each team who handles migration questions and reviews type-related PRs. Rotate the role to build expertise across the team.
- Migration documentation: Keep a living document that tracks which directories are migrated, which strict flags are enabled, and which patterns the team recommends. This reduces tribal knowledge and accelerates onboarding.
Handling Common Migration Challenges
Third-Party Libraries Without Types
Most popular libraries have types on DefinitelyTyped (@types/*). For untyped libraries, write minimal ambient declarations:
declare module 'untyped-library' {
export function doSomething(value: string): number;
---For internal modules that are not yet migrated, use // @ts-nocheck at the top of the file as a temporary escape hatch.
Dynamic Imports and Require
Dynamic require() calls do not carry type information. Replace them with static imports where possible. When dynamic loading is essential, type the module map:
const modules: Record<string, () => Promise<unknown>> = {
admin: () => import('./admin'),
user: () => import('./user'),
---;This approach gives you type-checking on the module keys while preserving dynamic behavior.
Circular Dependencies
Circular dependencies are easier to detect in TypeScript because the compiler enforces import ordering. If you encounter circular dependency errors, the typical fix is to extract shared types into a separate file that both modules import:
// types.ts — no runtime code
export interface SharedType { /* ... */ }
// a.ts — imports types, no circular dep
import type { SharedType } from './types';
// b.ts — imports types, no circular dep
import type { SharedType } from './types';The import type syntax ensures the import is erased at compile time, preventing circular dependency at runtime.
Build Pipeline Integration
Ensure your build pipeline handles mixed .js and .ts files:
// webpack.config.js
module.exports = {
module: {
rules: [
{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
],
},
resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] },
---;For Vite, no additional configuration is needed — it handles .ts files natively.
Migration Metrics
Track these metrics to measure migration progress:
- TypeScript adoption rate: Percentage of files in
.tsvs.js. - Type coverage: Ratio of typed variables/parameters to total (use
typescript-coverage-report). - Strict mode errors: Count of errors by strict flag, tracked over time.
- Build time: Should remain stable or improve as types are added (incremental builds help).
Success Story: Major Migrations
Several well-documented migrations demonstrate the pattern:
- Airbnb: Migrated a 1M+ line JavaScript codebase to TypeScript using
ts-migrate. The automated first pass renamed files and addedanytypes, then teams methodically replacedanywith specific types over 18 months. Result: 60% reduction in type-related production bugs. - Lyft: Incrementally migrated their React+Redux frontend over 12 months using the strangler pattern. Each PR that touched a JavaScript file also converted it to TypeScript. Result: Zero regression incidents during migration.
- Vercel: Migrated Next.js’s core to TypeScript with full
strictmode from day one, validating that even large framework codebases can adopt TypeScript without sacrificing performance or developer velocity.
These cases share common success factors: executive sponsorship, clear migration ownership, incremental delivery, and patient enforcement of type quality over time.
FAQ
How long does a migration take?
For a 50K-line codebase with a team of 5 developers, expect 3-6 months for full migration with the strangler pattern. A blitz migration might take 2-4 weeks but pauses feature work.
Should I migrate tests to TypeScript?
Yes, but after migrating the source code they test. Test files benefit from typed mocks, typed assertions, and catch-typo bugs in test fixtures.
What about configuration files (Webpack, ESLint)?
Keep them in JavaScript. TypeScript’s JSDoc support allows adding types to .js config files without renaming them.
How do I handle dynamic require calls?
Replace with static imports. If dynamic loading is essential, type the module map: type Modules = Record<string, () => Promise<any>>.
Does migration improve application performance?
No. TypeScript types are erased at compile time. Performance improvements come indirectly through better architecture and fewer runtime guards.