After a stretch of JSDoc-checked JavaScript, the question stops being abstract:
do we keep annotating .js files, or do we move the same code to TypeScript
syntax? Both are "typed." They are not the same job.
This is the comparison I wish I had pinned above my desk the last time a team argued from slogans instead of constraints.
Side-by-side
| Dimension | JSDoc + checkJs | TypeScript (.ts) |
|---|---|---|
| Source ships as written | Yes: run node file.js | Needs emit or a TS-aware runner |
| Syntax weight | Types live in comments | Types live in language syntax |
| Generics / mapped types | Possible, painful | First-class |
| Refactor tools | Good enough for small trees | Strong rename/move across projects |
| Declaration emit | Awkward / limited | declaration: true is normal |
| Onboarding curve | Lower if team already writes JS | Higher day one, lower steady state |
| Annotation noise | High on non-trivial APIs | Lower once inference kicks in |
| "Is CI typing?" risk | Easy to forget tsc on .js | Harder to pretend types are optional |
Neither column is morality. They are cost centers.
The same function, two spellings
JSDoc:
js/** * @template T * @param {T[]} items * @param {(item: T) => boolean} predicate * @returns {T[]} */ export function retain(items, predicate) { return items.filter(predicate); }
TypeScript:
tsexport function retain<T>(items: T[], predicate: (item: T) => boolean): T[] { return items.filter(predicate); }
Same checker family under the hood. The TypeScript version remains readable when
T grows constraints, when overloads appear, and when predicate itself needs
generics. The JSDoc version starts as clever and ends as a wall of asterisks.
Where JSDoc still wins
I keep JSDoc when the runtime artifact wants to stay plain JS:
- CLIs and repo scripts consumed with
node ./scripts/...and no build. - Packages that must be editable by people who do not want a TS toolchain locally (rare, but real for mixed skill sets).
- Progressive enrichment of a legacy folder without a big-bang rename.
The win is operational, not aesthetic. You get editor squiggles and a CI gate without introducing emit config, sourcemaps, or "which loader runs tests?" questions.
Where TypeScript pulls ahead hard
TypeScript earns the migration when any of these show up:
- Shared domain types across packages. Billing states, permission unions,
and API DTOs want a home that imports cleanly.
.tsmodules beat atypes.jsfull of typedefs copied with subtle drift. - Library boundaries. If someone else installs your package, they deserve
.d.tsfiles that match reality. Emitting declarations from JSDoc is a side quest I no longer volunteer for. - Non-trivial generics. Component props that take a
TFieldValuesmap, router params, or result types likeResult<T, E>, write them in TypeScript or accept that your "types" are vibes with extra punctuation. - Confident refactors. Renaming a property across apps in a monorepo is a TypeScript strength. I have watched JSDoc refactors miss call sites that were never annotated.
Migration triggers I trust
I do not migrate because of hype. I migrate when:
- JSDoc line count rivals implementation line count on hot modules.
- Two packages need the same typedef and the copy is already wrong once.
- A bug ships that a type would have blocked, and the surrounding module is
already under
checkJs, meaning we paid for types and still lost because the annotation surface was incomplete. - New feature work touches the untyped edges more than the typed core. That is the signal the island strategy failed.
Conversely, I delay migration when the code is stable, small, and already covered by good tests. Types are not the only correctness tool.
A migration shape that does not traumatize
When I do move:
- Enable
allowJs+checkJsif not already, get to green. - Rename file-by-file (
mv foo.js foo.ts), fix the syntax, keep behavior identical. - Prefer
isolatedModules-friendly code so tooling (Vite, esbuild) stays happy. - Delete JSDoc that became redundant; leave JSDoc that is still the best form of prose documentation.
I avoid "convert everything this weekend" unless the tree is tiny. Mixed
allowJs trees are fine for weeks. The goal is monotonic improvement, not
purity.
Takeaway
JSDoc is a bridge and a tactical choice. TypeScript is the steady-state syntax for product UI and shared libraries in the work I do. Choosing either without naming the deployment and refactor constraints is how teams collect toolchain resentment.
Next I shift arcs, into linting foundations for the monorepo: ESLint and Prettier as shared packages, not snowflake configs in every app.