Ajax玉兔Ajax玉兔AJAX.BLOG
Back to all posts
August 20, 20264 min read

How to Migrate from JS to TypeScript Fast: An Incremental Path, Not a Rewrite

No rewrite, no downtime. Migrate a legacy JS project to TypeScript step by step with allowJs + JSDoc + staged strictness, complete with a full tsconfig and a troubleshooting checklist.

TypeScriptJavaScriptEngineering

Pick Your Route: Incremental, Not a Rewrite

There are three ways to move from JS to TypeScript:

RouteApproachBest for
Rewrite from scratchStart a new TS project and re-type everythingProjects under 5,000 lines that are about to change heavily
Dual trackLeave legacy code alone; write new modules in TSTeams where nobody knows TS yet and need a training period
Incremental hybrid.js and .ts coexist in the same project; migrate file by fileYou have legacy code and can't stop the business (where most people are)

The last post covered why the migration is worth it; this one covers how. The core idea is a single sentence: put the TypeScript compiler to work for you from day one—but don't push it too hard at the start.

Step 1: Let the Project "Know" TypeScript, but Don't Get Strict Yet

Install typescript, create a tsconfig.json, and the key to the first version is turn compilation on, strictness off:

json
{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "allowJs": true, // allow .js files to participate in compilation "checkJs": false, // but don't check them yet—avoid drowning in thousands of errors on day one "strict": false, // same here: start in non-strict mode "noEmit": true, // type-check only; output is still handled by the bundler "skipLibCheck": true // skip type-checking of third-party declaration files: faster + fewer pitfalls }, "include": ["src"] }

allowJs is the foundation of incremental migration: it lets .ts files import existing .js modules directly, with neither the compiler nor the bundler complaining. From then on, you can migrate one file today and another tomorrow, and every intermediate state is fully shippable.

Step 2: Rename Files Starting from the Leaf Modules

Migration order matters: bottom-up, leaves before the trunk. Start with the utility functions, constants, and type definitions that have the fewest dependents; leave the callers alone for now. Once all the dependencies are in place, then move on to the business components.

bash
# A typical migration batch src/utils/format.js → src/utils/format.ts src/api/client.js → src/api/client.ts # depends on format src/pages/home.js → src/pages/home.ts # touch this one last

After changing each file, run tsc --noEmit once. Errors should only come from the file you just changed and its direct callers—if errors explode across half the project, that file's dependencies aren't migrated yet; revert it.

Step 3: For Files You Don't Want to Rename, Use JSDoc to Get Type Checking

Some files aren't good candidates for renaming in the short term (build scripts, config files, for example). TypeScript supports getting type capabilities through JSDoc comments in .js files—a middle ground with a very high cost/benefit ratio:

js
/** * @param {string} noteId * @param {{ tags?: string[] }} options * @returns {Promise<import('./types').Note>} */ export async function getNote(noteId, options) { // ... }

Combined with checkJs: false (only explicitly annotated files get checked) or adding // @ts-check to individual files, you can precisely control the scope of checking.

Step 4: The Right Way to Handle Implicit any

The biggest source of noise in legacy JS code is implicit any: untyped function parameters, untyped destructuring, untyped JSON parsing. The correct move during migration is annotate any first to keep the build green, then tighten one by one:

ts
// Migration v1: silence the compiler first function handleResponse(res: any) { return res.data.list; } // Migration v2 (same release or the next iteration): switch to real types interface ListResponse<T> { data: { list: T[]; total: number }; } function handleResponse<T>(res: ListResponse<T>) { return res.data.list; }

Don't Flip On strict All at Once

strict isn't a single switch but a group of five. Turning everything on at once yields thousands of errors; the team spends a week fixing them and then never dares to look at the error list again. Turning them on in stages is what gives you a rhythm.

Step 5: The Order for Turning On Strict Mode in Stages

Follow this order, with a few iterations between each level:

  1. noImplicitAny: true — eliminate implicit any: the biggest workload, and the biggest payoff
  2. strictNullChecks: true — force null handling; it will expose a batch of potential crashes
  3. strictFunctionTypes / strictBindCallApply — tighten function signatures
  4. strict: true (all on) — mop up the remaining ones like strictPropertyInitialization and noImplicitThis in one go

My actual pace: on a project of over thirty thousand lines, the noImplicitAny phase meant migrating two or three files a day, finished in two weeks; the strictNullChecks phase focused on about 200 null-safety risks, and it actually uncovered 3 pre-existing bugs.

Step 6: What to Do When a Third-Party Library Has No Type Declarations

Try in order of priority:

  1. The library ships its own types (a types field in package.json)—use them directly, nothing to do
  2. The @types/xxx community package—npm i -D @types/xxx
  3. Write your own declaration file—create src/types/xxx.d.ts:
ts
// an old library with no types declare module "legacy-widget" { export function init(el: HTMLElement, options?: Record<string, unknown>): void; export function destroy(): void; }
  1. Last resort: // @ts-ignore (suppresses all errors on the line—use with care) or // @ts-expect-error (errors when the next line has no error, so it's better for temporary suppression).

Common Pitfalls Checklist

PitfallSymptomFix
Import paths with .js extensionsIn some scenarios, a .ts file importing ./x.js throws TS5097 (extension not allowed)Drop the extension in bundler mode, or enable allowImportingTsExtensions
JSON imports have no typesimport data from './x.json' is anyresolveJsonModule: true
Non-code assets like CSS/imagesTS2307 cannot find moduleWrite a wildcard declaration declare module "*.css"
Node globals throw errorsprocess and __dirname don't existnpm i -D @types/node
Legacy library types don't match the implementationOnly fails at runtimeUse the as unknown as T double assertion as a fallback, with a comment explaining why

The Time Ledger and Mindset

  • Under 5,000 lines: one weekend
  • Around 30,000 lines: two to three weeks, one hour a day
  • The mindset that matters: the goal of a migration is "rising type coverage," not "zero errors". Every time you replace an any with a real type, you come out ahead; the people who abandon migrations halfway are usually the ones who turned on strict on day one.

Next post, back to algorithms: "Solving N-Queens with DFS: From the Backtracking Framework to Bit Manipulation", which also shows the right way to write algorithm problems in TypeScript.