Migrating a JavaScript Project
You don't need to stop everything and rewrite a JavaScript codebase to adopt TypeScript. Because TypeScript understands JavaScript, a project can move over one file at a time while staying shippable throughout. This lesson lays out a step-by-step plan that works for projects of any size.
Step 1: add TypeScript without changing any code
npm install --save-dev typescript @types/node npx tsc --init
Start with a permissive config that accepts JavaScript and doesn't emit anything — your existing build keeps working exactly as before:
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": false,
"noEmit": true,
"strict": false,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}
Note the explicit "strict": false: strict mode is the default since TypeScript 6.0, so a migration has to opt out of it at first. Run npx tsc. It should pass, or show only a few errors. You now have a type-check step, and your editor already gives better autocomplete. Add "typecheck": "tsc" to your scripts and run it in CI from day one, so new problems can't creep in.
Step 2: install missing type packages
Check your dependencies for ones without bundled types and add the matching @types/… packages (lesson 33). For anything that has none, a one-line declare module "name"; in a types.d.ts file unblocks you for now.
Step 3: convert files, leaves first
Rename files from .js to .ts (or .jsx to .tsx) one at a time. Start with "leaf" modules that import little else — utilities, constants, data models — and work up towards the entry points. Each converted file makes the files that import it better typed.
For each file:
- Rename it and fix import paths if needed.
- Annotate function parameters (they're usually the main source of errors).
- Define interfaces or types for the main data shapes, often in a shared
types.ts. - Fix the real bugs TypeScript finds — every migration finds some.
- Commit. Small, frequent commits keep the migration easy to review and easy to pause.
Common fixes during conversion
A handful of patterns account for most errors you'll meet:
// 1. Objects built up property by property // JS: const opts = {}; opts.retries = 3; type Options = { retries?: number; verbose?: boolean }; const opts: Options = {}; opts.retries = 3; // 2. Arrays that start empty const ids: number[] = []; ids.push(42); // 3. Values that may be missing function findName(users: { id: number; name: string }[], id: number): string { return users.find(u => u.id === id)?.name ?? "unknown"; } // 4. Untyped data from outside: start as unknown, narrow before use const parsed: unknown = JSON.parse('{"count": 2}'); const count = typeof parsed === "object" && parsed !== null && "count" in parsed ? Number(parsed.count) : 0; console.log(opts, ids, findName([{ id: 1, name: "Ana" }], 1), count);
{ retries: 3 } [ 42 ] Ana 2Step 4: escape hatches, used deliberately
When an error would take a long time to fix properly, it's fine to mark it and move on — as long as the marker is easy to find later:
// TODO(ts-migration): legacy event payload has no consistent shape const payload = event.detail as any; // @ts-expect-error -- third-party typings are wrong for this overload chart.render(data, true);
Prefer @ts-expect-error over @ts-ignore: it suppresses the error on the next line but reports an error itself if the line later becomes correct, so stale suppressions don't pile up. A lint rule can require a description after each one.
Step 5: tighten the screws
Once most files are converted, enable stricter options one at a time, fixing the fallout from each before moving on:
noImplicitAny— find the remaining untyped parameters.strictNullChecks— usually the biggest and most valuable step; it surfaces real null bugs.- The rest of
strict. - Extras like
noUncheckedIndexedAccess, if your team wants them.
On a very large codebase, a single flag might produce thousands of errors. Tools such as ts-migrate (from Airbnb) can insert temporary @ts-expect-error comments in bulk so you can turn a flag on immediately and clean up gradually.
Step 6: finish up
- Convert the remaining
.jsfiles and setallowJsback tofalse. - Add typescript-eslint with
no-explicit-anyto prevent newany(lesson 41). - Search for
as any,@ts-expect-errorandTODO(ts-migration)and work through them over time.
Practical advice
- Don't refactor and migrate in the same commit. Converting a file should change types, not behaviour. Mixing the two makes bugs hard to trace.
- Type the boundaries first. API responses, database rows and shared models give the most value per line of types written.
- Keep shipping. Because
allowJslets old and new code coexist, there's never a reason to freeze feature work for a migration.