Migrating a JavaScript project incrementally

allowJs and checkJs, turning strict flags on one at a time, JSDoc as a stepping stone, and keeping the debt visible in CI.

Turn the checker on gradually

The migration is a sequence of small steps, each of which leaves the build green. Start with allowJs so JavaScript files take part in the program, pick one leaf folder, then turn on a single flag and fix what it reports.

FlagWhat it catchesUsual first move
allowJsLets .js files into the programTurn on; change nothing else
checkJsChecks JavaScript as wellEnable on one folder at a time
noImplicitAnyUntyped parameters and propertiesAnnotate the failing signatures
strictNullChecksnull and undefined misuseThe big one - budget a sprint for it
strictThe rest of the familyEnable last, directory by directory
noUncheckedIndexedAccessIndex lookups that can be undefinedOptional, and a real behaviour change

JSDoc as a stepping stone

// checkJs reads JSDoc comments, so a .js file can be checked without renaming it
/**
 * @param {{ id: string, items: number[] }} order
 * @returns {number}
 */
export function total(order) {
  return order.items.reduce((sum, value) => sum + value, 0);
}

/**
 * @typedef {Object} User
 * @property {string} id
 * @property {string} name
 * @property {string} [email]
 */

/** @type {string | null} */
let token = null;

/**
 * @template T
 * @param {T[]} items
 * @param {(item: T) => boolean} test
 * @returns {T[]}
 */
export function filter(items, test) {
  return items.filter(test);
}

// @ts-expect-error: legacy global, removed in Q3
const legacy = globalThis.__oldCount;
  • JSDoc types are real types to the checker: the same inference, narrowing and error messages apply.
  • @ts-expect-error fails the build when the error disappears, unlike @ts-ignore, which silently outlives its reason.
  • JSDoc is a stepping stone, not a destination: once a file stops changing it is cheaper to rename it to .ts than to maintain comments.
  • Generated files and vendor bundles belong in exclude; checking them wastes time and cannot be fixed.

Suppressions as tracked debt

// a suppression is a debt note: it says "known, not fixed"
const payload = JSON.parse(raw) as any;        // bad: any leaks into every caller

// acceptable: constrain the escape hatch to one expression, with a reason
// @ts-expect-error third-party types are wrong here; upstream issue #412
legacyClient.send(payload);

// replace a real any with unknown as soon as the callers can handle it
function parse(raw: string): unknown { return JSON.parse(raw); }

// count the debt so it cannot grow unnoticed
// grep -rn "@ts-expect-error" src | wc -l

// and stop the count from rising in review: a new suppression needs a comment
  1. Add tsc --noEmit to CI with a narrow include, so only converted files are verified and main stays green.
  2. Convert leaves first - date helpers, formatters, pure utilities - because they have no dependencies to fix first.
  3. Turn on one flag per pull request, and fix the errors it reports in the same change.
  4. Track the suppression count as a number in the pipeline output, so the trend is visible instead of assumed.
⚠️
The migration fails when it is planned as a rewrite. Big-bang conversions stall after the branch diverges, and the half-migrated state is worse than the original. Convert one module per pull request, keep CI green, and never let a flag be enabled with the errors still outstanding.

FAQ

Should I rename files to .ts straight away?
Only when the file is stable. Adding JSDoc and enabling checkJs gives the same checking with a smaller diff, which is worth doing while the code is still changing. Rename when you are ready to stop writing comments.
How do we keep CI green during the migration?
Run the checker with an include that lists only converted folders, and grow that list with each step. A red main branch during a migration means the migration stops.

Declaration files and third-party types Typing asynchronous and external data

Last refreshed 2026-09-18.