Migrating a JavaScript Project to TypeScript Without Stopping Work
An incremental migration path from JavaScript to TypeScript — allowJs, per-file adoption, handling untyped dependencies, and what to do about the any that creeps in.
Table of contents
- Step 1: Add TypeScript without converting anything
- Step 2: Convert leaf modules first
- Step 3: Allow any, but make it visible
- Step 4: Handle untyped dependencies
- Step 5: Turn on strictness incrementally
- What to expect
- Frequently asked questions
- Should I convert tests too?
- Can I use JSDoc instead of converting files?
- How long does a migration take?
- Will TypeScript slow down my build?
- Related reading
- References
A big-bang TypeScript rewrite fails for the same reason every big-bang rewrite fails: it competes with shipping. The good news is that TypeScript was designed for gradual adoption, and a project can be half-migrated indefinitely without pain.
Step 1: Add TypeScript without converting anything#
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"strict": false,
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
}allowJs: true with checkJs: false means TypeScript reads your JavaScript for inference but reports no errors in it. npm run typecheck should pass on day one. If it does not, fix that before converting a single file.
Step 2: Convert leaf modules first#
Start at the bottom of the dependency graph — utilities, constants, pure functions. They have the fewest imports, so converting them is self-contained, and every module above them immediately gets better inference for free.
Convert the worst place last: whatever file everything imports and nothing understands.
Step 3: Allow any, but make it visible#
During migration you will need escape hatches. Make them greppable:
// A deliberate, temporary any with a reason
type TodoAny = any;
function legacyHandler(payload: TodoAny) {
/* ... */
}A named alias means grep TodoAny gives you an accurate remaining-work list, which any does not. Ban bare any with an ESLint rule so the alias is the only route.
Step 4: Handle untyped dependencies#
Three options, in order:
Check DefinitelyTyped. npm i -D @types/the-package covers most of the ecosystem.
Write a minimal declaration. You only need the parts you use:
// types/untyped-lib.d.ts
declare module 'untyped-lib' {
export function doThing(input: string): Promise<number>;
}This is dramatically better than any, and it takes minutes.
Last resort — a blanket module declaration:
declare module 'legacy-thing'; // everything from it is anyStep 5: Turn on strictness incrementally#
Once most files are .ts, enable the flags in this order: noImplicitAny, then strictNullChecks, then the rest of strict, then noUncheckedIndexedAccess. Each is a separate PR with a bounded set of errors.
A useful trick for large codebases is a second, stricter config that checks only migrated directories:
// tsconfig.strict.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "strict": true },
"include": ["src/lib/**/*", "src/utils/**/*"]
}Run both in CI. The strict-checked area grows one directory at a time and can never regress.
What to expect#
The errors TypeScript finds first are overwhelmingly of two kinds: values that can be null and were never checked, and functions called with the wrong argument shape. Both are real bugs. Expect to find genuine defects during migration — that is the return on the work, not a side effect.
Frequently asked questions#
Should I convert tests too?#
Yes, and it is a good early target: tests are leaf modules with no dependents, and typed test fixtures catch signature drift immediately.
Can I use JSDoc instead of converting files?#
Yes. With checkJs: true, TypeScript reads JSDoc annotations and type-checks plain .js. It is a legitimate end state for projects that do not want a build step, though the ergonomics are worse for generics.
How long does a migration take?#
For a 50,000-line codebase with a small team, expect weeks of background work rather than a dedicated sprint. The incremental approach means value arrives from week one instead of at the end.
Will TypeScript slow down my build?#
If you use a bundler that strips types without checking (esbuild, SWC, Vite), runtime builds stay the same speed and type checking runs separately in CI. That is the recommended setup.
Related reading#
- TypeScript Strict Mode — the flag order in detail
- Type vs Interface