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

$ terminal
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:

JSON tsconfig.json
{
  "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:

  1. Rename it and fix import paths if needed.
  2. Annotate function parameters (they're usually the main source of errors).
  3. Define interfaces or types for the main data shapes, often in a shared types.ts.
  4. Fix the real bugs TypeScript finds — every migration finds some.
  5. 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:

TS before-after.ts
// 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);
Output
{ retries: 3 } [ 42 ] Ana 2

Step 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:

TS escape-hatches.ts
// 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:

  1. noImplicitAny — find the remaining untyped parameters.
  2. strictNullChecks — usually the biggest and most valuable step; it surfaces real null bugs.
  3. The rest of strict.
  4. 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 .js files and set allowJs back to false.
  • Add typescript-eslint with no-explicit-any to prevent new any (lesson 41).
  • Search for as any, @ts-expect-error and TODO(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 allowJs lets old and new code coexist, there's never a reason to freeze feature work for a migration.