Migrating a Real Codebase from JavaScript to TypeScript Without Losing Your Mind

By James Nguyen Updated September 24, 2026
Migrating a Real Codebase from JavaScript to TypeScript Without Losing Your Mind

Our first attempt at migrating a genuinely large JavaScript codebase to TypeScript stalled about halfway through and sat in a broken, half-converted state for months, neither fully typed nor fully abandoned. The second attempt, done deliberately differently, actually finished, and here's what changed between the two.

Why the First Attempt Failed

We tried converting everything at once, renaming files to .ts across the whole codebase in one large pull request, which meant the entire team was blocked on a single, sprawling, difficult-to-review change for weeks, and momentum died once the initial excitement wore off and the errors kept accumulating faster than anyone could fix them.

The Incremental Approach That Actually Worked

tsconfig.json's allowJs option lets TypeScript and JavaScript files coexist in the same project, meaning we could convert one module at a time, merge each conversion as its own small pull request, and keep the rest of the codebase working normally throughout a migration that ultimately took several months rather than one disruptive sprint.

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "noImplicitAny": false
  }
}

Starting With Leaf Modules, Not the Core

Converting utility functions and isolated, low-dependency modules first, rather than starting with the application's core state management or routing logic, meant early conversions were low-risk and gave the team genuine wins to build confidence before tackling the more tangled, central parts of the codebase.

Gradually Tightening strict Mode

Rather than enabling strict mode immediately, which floods a partially converted codebase with errors, we tightened individual strict flags one at a time as coverage grew, noImplicitAny once most core modules were converted, then strictNullChecks once we'd audited the codebase's actual patterns around null and undefined handling.

The any Type as a Deliberate, Temporary Tool

Rather than blocking a conversion on perfectly typing every third-party interaction immediately, we allowed explicit any types with a TODO comment and a tracked follow-up ticket, treating incomplete typing as acceptable technical debt to address later rather than a blocker to merging genuine progress now.

// TODO(TICKET-4521): type this properly once the vendor SDK ships types
function processLegacyPayload(data: any) {
  return data.items.map((i: any) => i.id);
}

Handling Untyped Third-Party Libraries

For dependencies without official TypeScript definitions, DefinitelyTyped's community-maintained @types packages covered most of what we needed, and for the small remainder, writing minimal, deliberately loose custom type declarations unblocked the migration without demanding perfect, comprehensive typing for code we didn't own.

// types/legacy-widget.d.ts
declare module "legacy-widget" {
  export function init(config: unknown): void;
}

Where Real Bugs Surfaced During Conversion

Converting a genuinely old date-handling utility module, TypeScript's type checking surfaced a function that could silently receive undefined in a specific edge case that JavaScript had simply let pass through without error, a real latent bug that had apparently never been triggered in production but was discovered directly through the migration process itself.

Team Buy-In, the Non-Technical Part That Mattered Most

Setting a shared, realistic expectation that this would take months, not weeks, and celebrating incremental module conversions rather than only the eventual full completion, kept morale from collapsing the way it had during the failed first attempt where the lack of visible progress made the whole effort feel stalled.

Measuring the Actual Payoff

Roughly a year after starting, and several months after the codebase reached full TypeScript coverage, the team's own retrospective pointed to a genuine, noticeable drop in a specific category of production bug, incorrect assumptions about data shape passed between modules, exactly the kind of error static typing is built to catch.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles