Most TypeScript codebases did not start that way; they were JavaScript projects converted one file at a time while features kept shipping. TypeScript is designed for this: it can compile and even type-check .js files, and strictness can be turned up gradually. This lesson lays out a migration plan that keeps the build green at every step, from the first tsconfig.json to full strict mode, and lists the JavaScript patterns that need rewriting along the way.
Install the compiler and create a configuration that accepts the existing JavaScript unchanged:
npm install --save-dev typescript @types/node
npx tsc --init{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"allowJs": true,
"checkJs": false,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"strict": false
},
"include": ["src"]
}allowJs lets tsc (or your bundler) process .js files alongside .ts. With checkJs off, nothing is checked yet, so the build passes immediately. If a bundler already produces the output, add "noEmit": true and use tsc --noEmit purely as a checker. Commit this and wire tsc --noEmit into CI now, while it is trivially green.
Turn on checking for one file at a time by adding // @ts-check at its top (or set "checkJs": true and opt files out with // @ts-nocheck). TypeScript reads JSDoc annotations as types:
// @ts-check
/** @type {import("./config.js").AppConfig} */
const config = loadConfig();
/**
* @template T
* @param {T[]} items
* @param {(item: T) => boolean} predicate
* @returns {T | undefined}
*/
export function findFirst(items, predicate) {
return items.find(predicate);
}This phase surfaces real bugs (wrong argument order, misspelled properties, missing null handling) without renaming a single file. Install @types/* packages for dependencies as their imports start being checked.
Convert .js to .ts starting from modules with no internal imports (utilities, constants, models) and moving toward entry points. Each renamed file must compile, but it may lean on escape hatches temporarily:
unknown and narrow, or as any with a // TODO(types) comment you can grep for.// @ts-expect-error (not @ts-ignore) for lines you cannot fix yet; the directive errors when the problem disappears, so it cleans itself up.Record<string, unknown> and index signatures before redesigning it.Run the test suite after every batch of renames. Because the emitted JavaScript is nearly identical, behaviour should not change; if a test fails, the type error was pointing at a real bug.
Turning on strict all at once in a large project produces thousands of errors. Enable its members one at a time, fixing each to zero before the next:
| Order | Flag | Typical fixes |
|---|---|---|
| 1 | noImplicitAny | Add parameter types; replace implicit any with real types |
| 2 | strictNullChecks | Add ?., ??, guards; make optional fields explicit |
| 3 | strictFunctionTypes, strictBindCallApply | Correct callback parameter types |
| 4 | noImplicitThis, useUnknownInCatchVariables | Type this parameters; narrow catch values |
| 5 | strict: true | Remove the individual flags; add noUncheckedIndexedAccess if desired |
Track progress with a count of any occurrences or the @typescript-eslint/no-explicit-any rule set to warn, and prevent regressions by failing CI on new errors while old ones are being worked down.
Some idioms type poorly and are worth changing during the migration:
const user = {}; user.name = ...): declare the full type up front or build the object in one literal.keyof typeof obj and a guard instead of obj[anyString].arguments and dynamic this: replace with rest parameters and explicit this types or arrow functions.strict: true on a large legacy codebase and giving up under the error count.@ts-ignore liberally; it silences errors forever, whereas @ts-expect-error reports when it becomes unnecessary.Which option lets `tsc` process existing `.js` files during a migration?
allowJs and a passing tsc --noEmit in CI before changing any source file.// @ts-check and JSDoc to find bugs in JavaScript files before renaming them.unknown and @ts-expect-error as temporary escape hatches, and keep tests green.strict flags one at a time, noImplicitAny first, and track the count of remaining anys.Next lesson: TypeScript Best Practices and Common Mistakes — the habits that keep a typed codebase healthy over time.