JavaScript to TypeScript Migration: Incremental, Not Big Bang
AI generated
<T>
type
TypeScript · Migration · JavaScript · Tooling
JavaScript to TypeScript Migration
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.

14 min read allowJs · checkJs · JSDoc · type-coverage TypeScript 5.x · tsconfig.json · CI/CD

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.

11. FAQ: JavaScript to TypeScript Migration

1Why do big-bang rewrites fail so often during a TypeScript migration?
A full rewrite ties up the team for weeks while features are demanded in parallel. Feature branches drift apart, merge conflicts pile up, and there is no visible intermediate win.
2What exactly do allowJs and checkJs do in tsconfig.json?
allowJs pulls .js files into the project, checkJs additionally turns on type checking inside them, based on inference and JSDoc, without rewriting a single line of code.
3Do I have to rename files to benefit from TypeScript?
No. With checkJs and JSDoc, a .js file can be fully typed. The rename to .ts happens only once it is already free of type errors.
4In what order should I migrate files?
Leaf-first: utility modules first, then services, then components, entry points last. Prioritize modules with many callers but few imports of their own.
5How do I measure the progress of an ongoing migration?
With type-coverage as a percentage metric, supplemented by a count of .js versus .ts files and a CI gate with a threshold.
6How do I avoid ending up "half migrated forever"?
Through measurable intermediate goals, a CI gate against new .js files in migrated directories, and the team rule to only accept TypeScript reviews there.
7What is the problem with @ts-ignore during migration?
@ts-ignore suppresses errors silently and permanently. @ts-expect-error is usually better, because it throws an error itself once the suppression becomes unnecessary.
8How do I handle third-party libraries without their own types?
First check for an @types/* package via DefinitelyTyped. If none exists, write a minimal custom .d.ts declaration instead of treating the whole module as any.
9How do I coordinate a migration across multiple developers?
Through a shared list of currently migrated directories, pure rename commits via git mv kept separate from feature changes, and TypeScript-only reviews in migrated areas.
10Can I enable strict flags for only part of the project?
Yes, through nested tsconfig.json files with extends per directory, so migrated areas run under full strictness while the rest stays looser for now.