JavaScript-to-TypeScript migrations look straightforward in tutorials: turn on TypeScript, rename .js to .ts, add types, ship. In real codebases, that path is the easy 80%. The remaining 20% — and especially the last 5% — is where migrations slip months past their estimates.
We used AI Code Research on five real JS-to-TS migrations (totaling ~400K LOC across the projects). The patterns of slippage were consistent. Here's what they looked like and how to avoid them.
The five places migrations slip
1. Third-party libraries without types
You add "strict": true to tsconfig and the build fails on imports from libraries that don't ship type declarations. Common culprits: older Node.js libraries, internal company packages, niche utilities.
Time cost: hours to days per untyped library, depending on whether you write minimal .d.ts declarations or wait for upstream support.
Fix: maintain a types/ directory in your repo for hand-written declarations. Cover only the surface you use, not the full library API. Don't fall into the "declare module as any" pattern — it leaks untyped values into the rest of your codebase.
2. Deeply dynamic objects
Configuration objects, API responses, environment variables, query string parsers — these often have shapes that vary at runtime. JavaScript's dynamism made this trivial. TypeScript's static types make it explicit, which surfaces decisions you didn't realize you were making.
Example from migration #3: a config-loading function had 17 entry points across the codebase, each treating the config object as a different shape. Migrating required either standardizing the config shape or making the loader's return type a discriminated union.
Fix: at runtime boundaries (config loaders, API client responses, parsed query strings), validate with a schema (Zod, io-ts, valibot). The schema produces a real TypeScript type and validates at runtime. This pays off twice — at migration time and forever after.
3. Implicit any in legacy callbacks
Callback-heavy code from the early Node.js era often has parameters typed as any because they were never typed at all. TypeScript can't infer parameter types when callbacks come from external sources.
Time cost: small per occurrence, but legacy callback chains can have hundreds of occurrences.
Fix: eat the time. Type each callback parameter explicitly. Don't accept implicit any "to unblock the build" — it's the gateway pattern to a half-typed codebase that gives you neither JavaScript's flexibility nor TypeScript's safety.
4. Runtime-validated boundaries
The biggest payoff of TypeScript is at boundaries — between modules, between services, between the database and the application. The biggest pain of migration is often at the same boundaries.
Example from migration #5: a service that consumed messages from Kafka. The original JavaScript treated each message's payload as untyped. The migration plan was "type these messages." But what types? The producers were five different services; each had a slightly different schema; some fields were sometimes missing in production but never in test. Migrating required actually reading the production traffic to derive accurate schemas.
Fix: use schemas (Zod, etc.) at boundaries. Generate types from the schemas. The schemas double as runtime validators — they catch the cases tests miss.
5. "Temporary" type-assertion escapes that become permanent
When the build won't compile and you're trying to ship, the path of least resistance is to add as any or @ts-expect-error and move on. This is supposed to be temporary. It's almost never temporary.
Pattern from migration #2: 47 @ts-expect-error directives accumulated over 6 months. The migration was technically complete but full of these escape hatches. Removing them later took longer than typing properly the first time would have.
Fix: institutional discipline. Code review rejects as any, @ts-expect-error, and // @ts-ignore unless they reference a specific GitHub issue with a deadline. Sweep the directives quarterly to ensure they don't become permanent.
What "actually slipped" means in numbers
Across the 5 migrations:
- The easy 80%: straightforward file-by-file conversion. Days to weeks per chunk.
- The middle 15%: required meaningful design decisions. Weeks to months total.
- The hard 5%: became the project. Months on its own.
Underestimating that 5% is what blew up timelines. The original plans estimated linearly across the codebase: "200K LOC, 100 LOC/hour migration speed, 2,000 hours, ship in 6 months." Reality: the 80% takes that estimate; the 15% takes 2x that estimate; the 5% takes 5-10x.
The plan that ships builds in slack for the 5%. The plan that slips assumes the codebase is uniformly migratable.
The fastest path
From the migrations that finished cleanest:
1. Strict tsconfig from day one
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"allowJs": true,
"checkJs": false
}
}
allowJs lets the codebase mix JS and TS during the migration. Strict mode forces real types as you migrate.
2. File-by-file, never accept implicit any
A file is migrated when it has zero implicit any, zero as any (without justification), zero @ts-expect-error. If a file resists clean migration, that's a signal to redesign before typing — not to lower your standards.
3. Schemas at runtime boundaries before consumer types
For configs, API responses, queue messages, anything that crosses a boundary: write a Zod (or io-ts) schema first. Generate the type from the schema. Migrate consumers to use the typed result. The schema catches runtime drift the types alone can't.
Where AI helps most
The biggest leverage is in the research half — reading the existing JavaScript to extract the implicit type contracts.
An AI agent reading 200K-LOC JavaScript can:
- Identify untyped callback shapes by aggregating call sites
- Surface inconsistent shapes for "the same" config across the codebase
- Find runtime boundaries where validation is needed but missing
- Produce a migration sequence (which files to type first, which depend on others)
This is the kind of analysis that takes a senior engineer weeks of careful reading. With AI, it's hours.
The smaller leverage is in the build half — AI coding tools (Cursor, Claude Code) can apply types file-by-file once the plan is set. Helpful, but the biggest savings are upstream.
Where to drill in deeper
- Legacy Code Modernization — broader pattern at larger scale
- Monolith to Microservices — service decomposition migrations
- What Is AI Code Research? — the agent that read these migrations
Planning a JS-to-TS migration?
The fastest path is reading what you have first. → Try AI Code Research on your codebase — point it at your repo, ask "where will this migration slip" and "what's the right typing strategy." The output is a research artifact your team can review before writing migration code. Free to start.