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.
Pick Your Route: Incremental, Not a Rewrite
There are three ways to move from JS to TypeScript:
| Route | Approach | Best for |
|---|---|---|
| Rewrite from scratch | Start a new TS project and re-type everything | Projects under 5,000 lines that are about to change heavily |
| Dual track | Leave legacy code alone; write new modules in TS | Teams where nobody knows TS yet and need a training period |
| Incremental hybrid | .js and .ts coexist in the same project; migrate file by file | You 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:
{
"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.
# 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 lastAfter 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:
/**
* @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:
// 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:
noImplicitAny: true— eliminate implicitany: the biggest workload, and the biggest payoffstrictNullChecks: true— force null handling; it will expose a batch of potential crashesstrictFunctionTypes/strictBindCallApply— tighten function signaturesstrict: true(all on) — mop up the remaining ones likestrictPropertyInitializationandnoImplicitThisin 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:
- The library ships its own types (a
typesfield inpackage.json)—use them directly, nothing to do - The
@types/xxxcommunity package—npm i -D @types/xxx - Write your own declaration file—create
src/types/xxx.d.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;
}- 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
| Pitfall | Symptom | Fix |
|---|---|---|
Import paths with .js extensions | In 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 types | import data from './x.json' is any | resolveJsonModule: true |
| Non-code assets like CSS/images | TS2307 cannot find module | Write a wildcard declaration declare module "*.css" |
| Node globals throw errors | process and __dirname don't exist | npm i -D @types/node |
| Legacy library types don't match the implementation | Only fails at runtime | Use 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
anywith a real type, you come out ahead; the people who abandon migrations halfway are usually the ones who turned onstricton 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.