All articles
AI Code Research11 min read

We Read 5 JavaScript→TypeScript Migrations. Here's What Actually Slipped.

JS-to-TS migrations look straightforward in tutorials. In real codebases, the slippage shows up in five predictable places. We read five real migrations (totaling ~400K LOC) and identified the patterns that ship vs the ones that drag.

By AI Code Research

Key takeaways

  • JS-to-TS migrations slip in 5 predictable places: third-party libraries without types, deeply dynamic objects (configs, API responses), implicit any in legacy callbacks, runtime-validated boundaries, and 'temporary' type-assertion escapes that become permanent.
  • Across 5 real migrations totaling ~400K LOC, the shape of slippage was consistent: 80% of the migration is straightforward, 15% requires meaningful design decisions, 5% becomes the project. Underestimating that 5% is what blows up timelines.
  • The fastest paths share three patterns: (1) start with a strict tsconfig from day one, (2) migrate file-by-file with strict types — never accept implicit any to 'unblock,' (3) write Zod or io-ts schemas at runtime boundaries before adding types to the consumers.
  • AI-assisted migration changes the math. Reading the legacy JS first to extract the implicit type contracts (what shapes are passed where, what fields are sometimes missing) saves the most time. Type generation second.
  • The most common timeline failure: estimating from the easy 80% and missing the long tail. Plan for the 5% taking longer than the 80%.

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

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.

Next reads in this topic

Structured to move from head-term discovery to deeper, more citable cluster pages.

Try a HowWorks specialist agent

Stop reading about the work — run it. These specialist agents do the thing this article describes, end-to-end.

FAQ

How long does a JavaScript-to-TypeScript migration actually take?

Depends entirely on codebase size and how disciplined you are about avoiding implicit any. Per the 5 migrations we read: a 50K-LOC JS codebase with disciplined adoption took 6-8 weeks to migrate cleanly. A 200K-LOC codebase with mixed discipline took 4-6 months. The 'just turn on TypeScript and add any everywhere' approach takes weeks but produces a codebase that's TypeScript-shaped without TypeScript benefits — which is worse than not migrating.

Should I migrate file-by-file or whole-app at once?

File-by-file, almost always. TypeScript supports mixed JS/TS codebases — \`allowJs: true\` lets you migrate incrementally without breaking the build. The big-bang rewrite has the same failure modes as any big-bang rewrite. The exception: if your codebase is small (<10K LOC), big-bang can work and finishes faster.

What's the right tsconfig for a new TypeScript migration?

Strict mode from day one (\`strict: true\`), \`noImplicitAny: true\`, \`strictNullChecks: true\`, \`noUncheckedIndexedAccess: true\` if you can tolerate it. The temptation to start permissive and 'tighten later' almost never works — every implicit any you accept is technical debt that compounds. Start strict, accept that some files will take longer to migrate properly, and push through.

How do I handle libraries without TypeScript types?

Three options in order of preference. (1) Check DefinitelyTyped (\`@types/foo\`) — most popular libraries have community types. (2) Write minimal type declarations yourself in a \`.d.ts\` file covering only what you use. (3) As a last resort, declare the module as \`any\` and isolate its usage to one wrapper file. Avoid (3) where possible; it leaks types-of-any into your codebase.

Can AI tools speed up a JS-to-TS migration?

Yes, especially the research half. An AI agent reading the legacy JavaScript can extract implicit type contracts (what shape is passed to function X, what fields are sometimes undefined) and produce a typed-API description before you start migrating. AI coding tools (Cursor, Claude Code) then help apply types file-by-file. The bigger savings come from the research stage; most teams skip it and over-rely on tools auto-typing files.

Explore all guides, workflows, and comparisons

Use the HowWorks content hub to move from idea validation to build strategy, with practical playbooks and decision-focused comparisons.

Open content hub