Skip to content

Migration Strategy

A big-bang rewrite of a real JS codebase to TypeScript is how migrations die. The whole point of TypeScript is that it’s a gradual type system: JS and TS can coexist file by file, so you convert incrementally while shipping the whole time.

flowchart LR
  js["allowJs: TS builds
alongside JS"] --> checkjs["checkJs: type-check
the JS too"]
  checkjs --> convert["rename .js to .ts
file by file"]
  convert --> strict["ratchet strict flags
one at a time"]
A gradual migration ratchet
  1. Turn on allowJs. Now TypeScript compiles a mixed project — your .js files build unchanged, and you can start adding .ts files next to them.
  2. Add checkJs (and // @ts-check). TypeScript type-checks your JavaScript using inference and JSDoc. You get real errors before converting a single file — a free first pass.
  3. Convert file by file. Rename .js to .ts, fix the errors that surface, commit. Start at the leaves (utilities with few dependencies) and work toward the core.
  4. Ratchet strictness. Don’t start with full strict. Turn on flags one at a time — noImplicitAny, then strictNullChecks, then the rest — fixing each wave before enabling the next.

Each step is shippable. You are never in a broken half-migrated state for long.

During migration, any is a legitimate way to say “not yet typed — keep moving.” The danger is leaving it. Two disciplines keep it honest:

  • Make it explicit and greppable. Prefer an explicit any (or a // TODO: type this alias) over an implicit one, so you can find and burn them down later.
  • Ratchet with noImplicitAny. Once on, the compiler stops silently inserting any — new code must be typed, so the debt can only shrink.

When you must suppress an error, use @ts-expect-error, not @ts-ignore:

// @ts-expect-error — legacy shape, tracked in TICKET-123
legacyCall(weirdValue);

Both silence the error on the next line, but @ts-expect-error also errors if the line stops having an error. So when you later fix the underlying type, the now-unnecessary suppression fails the build and tells you to remove it. @ts-ignore just rots silently.

If you publish a package, a few extra rules apply:

  • Ship declaration files (.d.ts). Set "declaration": true so consumers get your types. Point "types" in package.json at the entry .d.ts.
  • Don’t leak internal types. Only your public API’s types are a contract; keep implementation types un-exported so you can change them freely.
  • Treat types as semver. A change that breaks consumers’ type checking is a breaking change, even if the runtime behavior is identical. Widening a parameter is usually safe; narrowing it, or changing a return type, can break builds downstream.
What is the recommended way to migrate a JS codebase to TypeScript?
What does `checkJs` (with @ts-check) give you before converting files?
Why prefer `@ts-expect-error` over `@ts-ignore`?
For a published library, why treat types as part of semver?