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.
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.
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
}
}
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.
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.
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);
}
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;
}
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.
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.
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.