Migrating a JavaScript Project to TypeScript

Advanced
13 min

Migrating a JavaScript Project to TypeScript

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.

Phase 0: Set Up Without Changing Any Code

Install the compiler and create a configuration that accepts the existing JavaScript unchanged:

bash
npm install --save-dev typescript @types/node npx tsc --init
json
{ "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.

Phase 1: Check JavaScript With JSDoc

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:

javascript
// @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.

Phase 2: Rename Files Leaf-First

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:

  • Annotate unknown shapes as unknown and narrow, or as any with a // TODO(types) comment you can grep for.
  • Use // @ts-expect-error (not @ts-ignore) for lines you cannot fix yet; the directive errors when the problem disappears, so it cleans itself up.
  • Keep JavaScript-style dynamic code working with 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.

Phase 3: Ratchet Up Strictness

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.

JavaScript Patterns That Need Rewriting

Some idioms type poorly and are worth changing during the migration:

  • Objects built up over time (const user = {}; user.name = ...): declare the full type up front or build the object in one literal.
  • Functions that take different argument shapes: split them, or write overloads.
  • String-keyed lookups on typed objects: use 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.
  • Monkey-patching globals or library prototypes: keep it, but add a declaration merge so the compiler knows about it.

Common mistakes

  • Renaming everything in one pull request; reviews stall and the branch rots. Migrate in small, mergeable batches.
  • Starting with strict: true on a large legacy codebase and giving up under the error count.
  • Using @ts-ignore liberally; it silences errors forever, whereas @ts-expect-error reports when it becomes unnecessary.
Quick Quiz
Question 1 of 3

Which option lets `tsc` process existing `.js` files during a migration?

Key Takeaways

  • Start with allowJs and a passing tsc --noEmit in CI before changing any source file.
  • Use // @ts-check and JSDoc to find bugs in JavaScript files before renaming them.
  • Rename leaf modules first, use unknown and @ts-expect-error as temporary escape hatches, and keep tests green.
  • Enable strict flags one at a time, noImplicitAny first, and track the count of remaining anys.
  • Rewrite dynamic idioms (accumulated objects, polymorphic arguments, string-keyed access) into typed equivalents.

Next lesson: TypeScript Best Practices and Common Mistakes — the habits that keep a typed codebase healthy over time.

Migrating a JavaScript Project to TypeScript - TypeScript | CodeYourCraft | CodeYourCraft