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:
tscalready runs in CI on the JavaScript tree. PreferallowJs+checkJswith a realinclude. If CI never typechecked the.js, the first.tsfile will look like TypeScript "broke the build" when it only exposed debt.- Emit strategy is decided. App code via Vite/esbuild does not need
tscemit. Library packages that ship.d.tsdo. Mixing those without naming who emits what is how you get duplicatedist/folders and confused consumers. - One package is the pilot. Migrating five apps in parallel guarantees five
slightly different
tsconfigdialects. 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: trueon day one of migration is fine if the pilot package is already green undercheckJs. Flipping strict and renaming in the same PR is how reviews stall. Prefer green JS under strict-ish checking, then rename.verbatimModuleSyntaxforcesimport type/export typehonesty. 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:
- Leaf utilities with stable APIs: pure functions, formatters, validators. High type payoff, low React surface area.
- Shared domain types that were
@typedefwalls, move them totypes.tsor colocated modules and delete the comment duplicates. - Data-layer modules (API clients, parsers) where wrong shapes hurt.
- 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:
tstype 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.jsin a package under migration. - Touching a
.jsfile is permission to rename it if the change is more than a typo, otherwise you pay the context switch twice. - Do not disable
checkJsto "make progress." That reintroduces the island problem from the JSDoc era. - Keep
// @ts-checkfile pragmas out of the story once package-levelcheckJsis 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:
allowJscan flip tofalse(or the remaining.jsfiles are intentional exceptions documented ininclude/exclude).tsc --noEmitis 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.