Skip to content

JSDoc vs TypeScript: Where the Trade-Offs Bite

An explicit comparison of JSDoc-checked JavaScript and TypeScript, migration triggers I trust, and the trade-offs that actually show up in day-to-day work.

4 min read
JSDoc
TypeScript
Migration
JavaScript
checkJs

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

DimensionJSDoc + checkJsTypeScript (.ts)
Source ships as writtenYes: run node file.jsNeeds emit or a TS-aware runner
Syntax weightTypes live in commentsTypes live in language syntax
Generics / mapped typesPossible, painfulFirst-class
Refactor toolsGood enough for small treesStrong rename/move across projects
Declaration emitAwkward / limiteddeclaration: true is normal
Onboarding curveLower if team already writes JSHigher day one, lower steady state
Annotation noiseHigh on non-trivial APIsLower once inference kicks in
"Is CI typing?" riskEasy to forget tsc on .jsHarder 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:

ts
export 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:

  1. Shared domain types across packages. Billing states, permission unions, and API DTOs want a home that imports cleanly. .ts modules beat a types.js full of typedefs copied with subtle drift.
  2. Library boundaries. If someone else installs your package, they deserve .d.ts files that match reality. Emitting declarations from JSDoc is a side quest I no longer volunteer for.
  3. Non-trivial generics. Component props that take a TFieldValues map, router params, or result types like Result<T, E>, write them in TypeScript or accept that your "types" are vibes with extra punctuation.
  4. 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:

  1. Enable allowJs + checkJs if not already, get to green.
  2. Rename file-by-file (mv foo.js foo.ts), fix the syntax, keep behavior identical.
  3. Prefer isolatedModules-friendly code so tooling (Vite, esbuild) stays happy.
  4. 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.