Incremental, Not Big Bang
A full rewrite of a grown JavaScript codebase almost always fails in practice against time pressure, live operations, and merge conflicts. This article shows how allowJs and checkJs surface type errors without renaming a single file, which order of migration actually holds up, and how teams bring the migration to a measurable finish instead of getting stuck in a permanent in-between state.
Table of Contents
- 1. Why Big-Bang Rewrites Fail in Reality
- 2. allowJs and checkJs: Finding Type Errors Without Renaming
- 3. JSDoc Type Annotations as an Intermediate Step in .js Files
- 4. Leaf-First: The Practical Migration Order
- 5. Renaming Files and Enabling Strict Flags Deliberately
- 6. Measuring Progress: type-coverage and CI Gates
- 7. Common Pitfalls: any, @ts-ignore, and Missing Types
- 8. Team Process: Code Review Rules for Multi-Developer Migrations
- 9. Big Bang vs. Incremental Migration Compared
- 10. Summary
- 11. FAQ
1. Why Big-Bang Rewrites Fail in Reality
The idea of translating a JavaScript codebase into TypeScript in one single sweeping step sounds clean on a whiteboard, but it almost always fails in grown projects against three factors: time pressure, merge conflicts, and ongoing operations. A rewrite ties up a team for weeks or months while new features are demanded in parallel, the feature branch inevitably drifts away from the main branch, and merging it back becomes a project of its own. Meanwhile, bugfixes pile up on both branches and have to be maintained twice over.
On top of that comes a psychological effect: a big-bang rewrite has no visible intermediate win. Either it is finished, or it delivers no value at all for weeks, a classic pattern that erodes management support the moment other priorities intervene. Incremental migration reverses that risk: every migrated file delivers measurable value in the form of type safety right away, without the rest of the application needing to be touched at all. The code stays runnable, testable, and deployable at every point in time, because TypeScript accepts plain JavaScript as valid input and the migration can proceed step by step, file by file, without any downtime for the feature work.
2. allowJs and checkJs: Finding Type Errors Without Renaming
The first concrete step of any incremental migration is not renaming a single file, but adding a tsconfig.json with the allowJs and checkJs flags. allowJs lets the TypeScript compiler treat .js files as part of the project and link them together with .ts files. checkJs goes a step further and turns on type checking inside those unchanged .js files too, based on type inference and any existing JSDoc comments, without a single line of code having to be rewritten.
This makes it possible to capture the actual state of a codebase objectively before any migration even begins: tsc --noEmit runs across the whole project and lists every type error already present in the existing code. Often that number is surprisingly high, because implicit any types, inconsistent return values, and silently mis-called functions suddenly become visible. It is important to run checkJs project-wide as non-blocking at first, meaning it must not break the build, and instead document the error count as a baseline metric against which the migration's progress can be measured later on.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"checkJs": true,
"strict": false,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src/**/*.js", "src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.js"]
}
3. JSDoc Type Annotations as an Intermediate Step in .js Files
Once checkJs is active, individual .js files can be typed using JSDoc comments without changing the file extension at all. This is the crucial intermediate step that makes big-bang rewrites unnecessary: function signatures, parameters, and return values get precise types while the file still runs as plain JavaScript, gets processed by any build tool without an extra transpile step, and stays readable, unchanged, for colleagues without TypeScript experience. The TypeScript compiler treats JSDoc types just as seriously as native type annotations and reports violations with the same precision.
This approach is especially valuable for utility functions and helpers that get called from many places: a mistyped parameter order or a forgotten optional parameter shows up immediately in the editor, long before the file is ever renamed to .ts. For more complex structures like object shapes or union types, @typedef blocks can be defined and referenced project-wide via import() types inside JSDoc, a fully-fledged type layer without creating a single .ts file.
// @ts-check
// utils/formatPrice.js - fully typed via JSDoc, still plain JavaScript
/**
* @typedef {Object} PriceOptions
* @property {string} currency
* @property {number} [decimals]
*/
/**
* Formats a numeric price value into a localized currency string.
* @param {number} amount
* @param {PriceOptions} options
* @returns {string}
*/
function formatPrice(amount, options) {
const decimals = options.decimals ?? 2;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: options.currency,
minimumFractionDigits: decimals,
}).format(amount);
}
module.exports = { formatPrice };
4. Leaf-First: The Practical Migration Order
The order in which files get migrated decides whether the whole effort succeeds or turns into frustration. A leaf-first approach has proven itself: start with pure utility modules that have no or minimal internal dependencies, then service and data-access layers, then UI components, and only at the very end entry points and bootstrapping code. The reasoning lies in the direction of dependencies: a utility function gets imported by many other modules but barely imports anything itself. Typing it first means every caller automatically benefits from more precise types, without those callers needing to be migrated themselves yet.
Starting with the entry point instead would mean sitting on a mountain of unresolved dependencies to still-untyped modules right away, forcing any or blanket type assertions everywhere, exactly the pattern that later sits around as technical debt. A dependency-graph tool such as madge or dependency-cruiser helps visualize a project's actual import structure and identify modules without circular dependencies as safe first candidates. Circular dependencies should always be resolved before migration, since they cause problems both for typing and for the module system itself.
# Analyze the dependency graph to identify leaf modules
npx madge --circular src/
npx madge --image graph.svg src/index.js
# Modules with no incoming project-internal imports are the first candidates
npx dependency-cruiser --output-type err-long src \
--config .dependency-cruiser.js
5. Renaming Files and Enabling Strict Flags Deliberately
Only once a file is already largely typed via JSDoc and free of type errors does the actual renaming step from .js to .ts follow. That is usually a small, low-risk commit, because the type information already exists and merely gets converted into native TypeScript syntax: JSDoc comments become real type annotations, module.exports becomes ES module syntax, unless the project already uses ESM anyway. Every rename should happen as its own small commit that touches exactly that one file, so code reviews stay manageable and the git history remains cleanly traceable if needed.
Strict flags such as strictNullChecks or noImplicitAny do not have to be active project-wide right away. TypeScript allows staggering strictness per directory through nested tsconfig.json files with extends: already-migrated directories run under full strictness, while the rest of the codebase keeps running with looser settings for the time being. That way, type safety grows gradually alongside migration progress, instead of throwing hundreds of new errors at the entire project on a single cutover day.
// utils/formatPrice.ts - renamed from .js, JSDoc becomes native types
export interface PriceOptions {
currency: string;
decimals?: number;
}
export function formatPrice(amount: number, options: PriceOptions): string {
const decimals = options.decimals ?? 2;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: options.currency,
minimumFractionDigits: decimals,
}).format(amount);
}
// tsconfig.json in src/utils overrides the project-wide config
// {
// "extends": "../../tsconfig.json",
// "compilerOptions": {
// "strict": true,
// "noImplicitAny": true
// }
// }
6. Measuring Progress: type-coverage and CI Gates
A migration without measurable progress quickly loses priority once other tasks start pressing. The type-coverage tool calculates the percentage of code positions with a concrete, non-any type, giving a single, easy-to-communicate metric that can be tracked sprint by sprint. On top of that, a simple script counting how many .js files remain versus .ts files gives a rough but stakeholder-friendly progress indicator.
The most effective lever against stalling, however, is a CI gate that actively blocks new .js files from appearing in directories that are already migrated. Without such a guard, under time pressure new, untyped code keeps sneaking back into supposedly migrated areas, and the progress bar moves backward. A simple lint check or a small Node script in the pipeline that looks for newly added .js files outside defined exceptions is usually enough, and it can be implemented in just a few lines.
#!/usr/bin/env bash
# ci/check-type-coverage.sh - fail the pipeline below a coverage threshold
set -euo pipefail
THRESHOLD=85
COVERAGE=$(npx type-coverage --detail --strict | tail -1 | grep -oP '\d+(?=\.\d+%)')
echo "Current type coverage: ${COVERAGE}%"
if [ "$COVERAGE" -lt "$THRESHOLD" ]; then
echo "Type coverage below threshold of ${THRESHOLD}%. Failing build."
exit 1
fi
# Reject new plain .js files inside already-migrated directories
if git diff --name-only --diff-filter=A origin/main...HEAD \
| grep -E '^src/(utils|services)/.*\.js$'; then
echo "New .js files are not allowed in migrated directories."
exit 1
fi
7. Common Pitfalls: any, @ts-ignore, and Missing Types
The most common regression in an ongoing migration is implicit any, flowing back into already-typed areas through untyped function parameters or return values from code that has not been migrated yet. Without noImplicitAny, TypeScript silently accepts such gaps, and the type safety of an otherwise cleanly migrated file gets undermined from the outside. Regular type-coverage runs with trend reporting reliably catch these creeping regressions long before they turn into a real problem.
A second trap is the uncontrolled buildup of @ts-ignore comments as a quick fix for annoying errors under time pressure. Every @ts-ignore is a deliberate blind spot in the type system and should never be added without a comment; an accompanying reason in the code plus a ticket reference at least turns it into visible, trackable debt instead of silent darkness. @ts-expect-error is the better choice in most cases, because it throws a compiler error the moment the suppressed error no longer exists, a built-in reminder to remove the suppression again. Third-party libraries without their own type definitions can be retrofitted via @types/* packages from DefinitelyTyped, or failing that, with a minimal custom .d.ts declaration instead of a blanket any for the whole module.
8. Team Process: Code Review Rules for Multi-Developer Migrations
Once several developers work on the same migration in parallel, coordination becomes the real challenge. Without clear agreements, two people might migrate the same file at the same time, or a rename from .js to .ts collides with an unrelated feature branch touching the same file, producing merge conflicts that git could actually resolve well for a pure rename, but no longer reliably separates once simultaneous content changes are involved. A simple board or a shared list of which directories are currently "in migration" prevents most of these collisions.
For code reviews, one fixed rule has proven itself: pure rename commits with no content change get reviewed separately from feature commits and are ideally carried out with git mv instead of delete-and-recreate, so the diff view recognizes the rename instead of showing an entire file as newly added. New pull requests that touch code in already-migrated directories should generally be required to be submitted in TypeScript rather than JavaScript, a simple rule documented in the review template that keeps everyday feature work from diluting migration progress again.
9. Big Bang vs. Incremental Migration Compared
The following overview contrasts risky big-bang patterns with the recommended incremental alternatives that have proven themselves in practice on live Magento and Node.js projects.
| Aspect | Big-Bang Approach (risky) | Incremental Approach (recommended) |
|---|---|---|
| Scope per step | Rewrite the entire codebase at once | One file or module per commit |
| Strict flags | Enforce strict: true project-wide immediately | Staggered per directory via extends |
| Operational risk | Long-lived feature branch, high merge risk | main stays deployable at all times |
| Progress visibility | No value delivered until completion | type-coverage visibly rises every sprint |
| Error handling | Hundreds of errors on one cutover day | checkJs surfaces errors early and locally |
The decisive difference is not in the end state, both paths are meant to lead to a fully typed codebase, but in the risk along the way. Incremental migration keeps the project runnable at every point in time and can be paused whenever needed, without leaving a half-finished rewrite behind as dead weight.
Mironsoft
TypeScript migrations and type-safe frontend architecture for Magento and Hyva
Ready to migrate your JavaScript codebase to TypeScript, step by step?
We analyze your codebase, define a realistic leaf-first order, and set up allowJs, checkJs, and CI gates so the migration makes measurable progress without putting live operations at risk.
Migration audit
Dependency graph, baseline type coverage, and per-module risk assessment
tsconfig setup
allowJs, checkJs, and staggered strict flags per directory
CI/CD gates
type-coverage thresholds and protection for already-migrated directories
10. Summary
A JavaScript-to-TypeScript migration does not succeed through a bold rewrite, but through consistent small steps that keep the codebase runnable at every point in time. allowJs and checkJs make type errors in existing .js files visible without a single file needing to be renamed. JSDoc type annotations deliver real type safety as an intermediate step, before the actual rename to .ts takes place. A leaf-first order, starting with utility modules and ending with entry points, makes sure every migrated file immediately creates value for its callers.
The decisive success factor against the "half migrated forever" state is measurability: type-coverage as a metric, CI gates against new .js files in migrated areas, and clear team rules for rename commits keep the migration from stalling out in day-to-day business. Anyone who combines these building blocks consistently reaches a fully typed codebase without ever putting operations at risk.
JavaScript to TypeScript Migration: The Essentials at a Glance
allowJs + checkJs first
Surface type errors in existing .js files without rewriting or renaming a single line of code.
Migrate leaf-first
Utility modules before services, services before components, components before entry points.
Measure progress
type-coverage as a metric and CI gates against new .js files in migrated areas.
Avoid the traps
Consistently prevent implicit any, uncommented @ts-ignore, and missing types for third-party packages.