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.
| Flag | What it catches | Usual first move |
|---|---|---|
allowJs | Lets .js files into the program | Turn on; change nothing else |
checkJs | Checks JavaScript as well | Enable on one folder at a time |
noImplicitAny | Untyped parameters and properties | Annotate the failing signatures |
strictNullChecks | null and undefined misuse | The big one - budget a sprint for it |
strict | The rest of the family | Enable last, directory by directory |
noUncheckedIndexedAccess | Index lookups that can be undefined | Optional, 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-errorfails 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
.tsthan 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- Add
tsc --noEmitto CI with a narrowinclude, so only converted files are verified and main stays green. - Convert leaves first - date helpers, formatters, pure utilities - because they have no dependencies to fix first.
- Turn on one flag per pull request, and fix the errors it reports in the same change.
- 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.Related
Declaration files and third-party types Typing asynchronous and external data
Last refreshed 2026-09-18.