Skip to content

Making the Leap: Migrating JS to TypeScript

A file-by-file TypeScript migration that keeps CI green, the sequence I use after JSDoc stops scaling, and the failure modes that turn leaps into rewrites.

6 min read
TypeScript
Migration
JavaScript
allowJs
checkJs
Monorepo

We took a lint detour, shared ESLint/Prettier, flat config, pre-commit hooks - with the monorepo foundation in place. Back to the TypeScript arc.

In the JSDoc vs TypeScript comparison, I sketched a migration shape that does not traumatize: green under checkJs, rename file-by-file, keep behavior identical. This post is that shape with the sharp edges filled in, what I actually flip in tsconfig, what I convert first, and what breaks when teams treat "migrate to TypeScript" as a weekend project.

Preconditions before the first rename

I do not start renaming until three things are true:

  1. tsc already runs in CI on the JavaScript tree. Prefer allowJs + checkJs with a real include. If CI never typechecked the .js, the first .ts file will look like TypeScript "broke the build" when it only exposed debt.
  2. Emit strategy is decided. App code via Vite/esbuild does not need tsc emit. Library packages that ship .d.ts do. Mixing those without naming who emits what is how you get duplicate dist/ folders and confused consumers.
  3. One package is the pilot. Migrating five apps in parallel guarantees five slightly different tsconfig dialects. Pick the package with shared types pressure, get a pattern, then copy the pattern.

If those are missing, pause. JSDoc for another sprint is cheaper than a half migrated tree nobody trusts.

The config I land first

json
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "strict": true, "noEmit": true, "allowJs": true, "checkJs": true, "skipLibCheck": true, "isolatedModules": true, "esModuleInterop": true, "resolveJsonModule": true, "verbatimModuleSyntax": true }, "include": ["src"] }

Notes I argue about every time:

  • strict: true on day one of migration is fine if the pilot package is already green under checkJs. Flipping strict and renaming in the same PR is how reviews stall. Prefer green JS under strict-ish checking, then rename.
  • verbatimModuleSyntax forces import type / export type honesty. It surprises people who wrote ambient-looking value imports. Fix the imports; do not disable the flag to silence the lesson.
  • moduleResolution: "bundler" matches Vite/Next-style apps. Node library packages may want "nodenext", do not copy the app config into a publishable package without thinking.

Strict flags that need a plan: noUncheckedIndexedAccess, exactOptionalPropertyTypes needs its own migration pass. I stick to a monotonic path you can ship incrementally.

File order that keeps momentum

I convert in this order:

  1. Leaf utilities with stable APIs: pure functions, formatters, validators. High type payoff, low React surface area.
  2. Shared domain types that were @typedef walls, move them to types.ts or colocated modules and delete the comment duplicates.
  3. Data-layer modules (API clients, parsers) where wrong shapes hurt.
  4. UI components last when props are still mushy. Converting a component whose props are Record<string, any> in spirit just relocates the mush.

Anti-pattern: renaming index.js barrels first. Barrels re-export everything; one bad type at the edge floods the graph.

One file, two states

Before (JSDoc, already checked):

js
/** * @param {{ id: string, status: 'open' | 'closed' }} ticket * @param {'open' | 'closed'} next */ export function transition(ticket, next) { if (ticket.status === next) return ticket; return { ...ticket, status: next }; }

After:

ts
type TicketStatus = 'open' | 'closed'; type Ticket = { id: string; status: TicketStatus; }; export function transition(ticket: Ticket, next: TicketStatus): Ticket { if (ticket.status === next) return ticket; return { ...ticket, status: next }; }

Same runtime. The win is not aesthetics, it is that Ticket can move to a shared package without copying a typedef block into three repos.

Mixed tree rules

allowJs mixed trees are normal for weeks. Rules that keep them sane:

  • New files are .ts / .tsx. No new .js in a package under migration.
  • Touching a .js file is permission to rename it if the change is more than a typo, otherwise you pay the context switch twice.
  • Do not disable checkJs to "make progress." That reintroduces the island problem from the JSDoc era.
  • Keep // @ts-check file pragmas out of the story once package-level checkJs is on, two mechanisms invite drift.

Failure modes I have actually hit

Big-bang rename PRs. Thousands of lines of mechanical mv plus drive-by refactors. Reviewers rubber-stamp or rage-quit. Split: mechanical renames with behavior lock, then typed improvements.

any as migration lubricant. A sea of any compiles and teaches nothing. Prefer unknown at boundaries and narrow. Temporary as casts at the edges of untyped JSON are honest; any in shared helpers is mold.

Two TypeScript versions in a monorepo. pnpm can hoist surprises. Pin one typescript in the workspace and make packages use it. Version skew shows up as "works on my machine" editor errors.

Path alias roulette. @/ pointing at different roots per package breaks the moment you share a file. Decide alias policy before mass rename, or stay on relative imports until the graph settles.

Tests still on a JS-only runner config. You migrate src/, CI still runs tests without type awareness, and production types diverge from test fixtures. Point the test runner at the same tsconfig story (or an explicit test config that extends it).

What "done" means for a package

I call a package migrated when:

  • allowJs can flip to false (or the remaining .js files are intentional exceptions documented in include/exclude).
  • tsc --noEmit is green in CI on every PR.
  • Declaration emit (if needed) is produced by the package's agreed pipeline.
  • JSDoc type annotations that became redundant are deleted; prose JSDoc that explains why can stay.

Done is not "every generic is elegant." Done is "the checker is a gate, and new work lands in TypeScript by default."

Takeaway

Migrating to TypeScript is a sequencing problem, not a belief system. Keep CI honest on JavaScript first, rename leaf-to-edge, ban new .js in the pilot, and treat any as debt with an owner, not as the migration strategy.

Next: the flags inside strict, and which extras earn their keep once the leap is behind you.