There is a middle path between "hope the runtime is kind" and "full TypeScript migration this quarter": keep JavaScript source, add JSDoc types, and let the TypeScript language service tell you when you are wrong.
I started using this seriously for scripts and packages that were not ready for
a .ts rename marathon, but were already too important for vibes. The point is
not to avoid TypeScript forever, it is to buy correctness without forcing a
rename-and-emit project onto code that still needs to run as plain .js.
The smallest useful setup
You do not need a tsc emit step. You need the checker looking at JS:
json{ "compilerOptions": { "allowJs": true, "checkJs": true, "noEmit": true, "strict": true, "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "maxNodeModuleJsDepth": 0 }, "include": ["src/**/*.js"] }
checkJs is the switch. Without it, JSDoc is documentation cosplay. With it,
your editor and tsc --noEmit become a real gate.
I keep maxNodeModuleJsDepth at 0 so I am not suddenly typechecking the
internet through poorly typed packages.
Annotations that earn their keep
The high-value patterns are function contracts and object shapes:
js/** * @typedef {{ * id: string, * email: string, * role: 'admin' | 'member' * }} User */ /** * @param {unknown} value * @returns {value is User} */ export function isUser(value) { if (typeof value !== 'object' || value === null) return false; const candidate = /** @type {Record<string, unknown>} */ (value); return ( typeof candidate.id === 'string' && typeof candidate.email === 'string' && (candidate.role === 'admin' || candidate.role === 'member') ); } /** * @param {User} user * @param {{ includeEmail?: boolean }} [options] */ export function formatUserLabel(user, options = {}) { if (options.includeEmail) { return `${user.id} <${user.email}>`; } return user.id; }
A few habits that keep this readable:
- Prefer
@typedef(or a sharedtypes.jswith typedefs) over repeating huge inline object types. - Use
@returns {value is T}for guards, that is where JSDoc becomes a power tool, not a comment. - Cast sparingly with
/** @type {T} */ (expr). Every cast is a place you told the checker to trust you.
Imports work if you type the boundary:
js/** * @param {import('./user.js').User} user */ export function toPublicProfile(user) { return { id: user.id, role: user.role }; }
What it catches well
In my experience, JSDoc + checkJs is excellent at:
- Wrong argument order (
formatUserLabel(options, user)). - Accessing properties that do not exist on a typedef.
- Basic nullability when you actually annotate it.
- Exhaustiveness pressure on string unions in simple switches.
That is already enough to stop an entire class of "undefined is not a function" bugs in internal tooling.
What it misses: or makes awkward
This is where honesty matters.
Generics get ugly fast. Mapped and conditional types are technically
reachable through @template and some creative typedefs, but the syntax fights
you. The moment you want DeepPartial<T> or a properly typed event map, you
will yearn for .ts.
Inference is thinner. In TypeScript, a well-typed helper often teaches the compiler about callers automatically. In JSDoc-land you annotate more call sites because the checker has less structural help from syntax.
Tooling integration is uneven. Some bundlers and test runners cope fine. Some IDE features (refactors, organize imports across a rename) feel second class compared to native TypeScript files.
Annotations rot when nobody runs the checker. A stale @param {string} on a
function that now takes an object is worse than no annotation, it teaches the
wrong thing. If checkJs is not in CI, treat JSDoc as optional notes, not
types.
A pragmatic boundary
I use JSDoc when:
- The package is still
.jsfor shipping simplicity (no emit, no sourcemap drama). - The API surface is small enough that typedefs stay readable.
- I want a migration on-ramp, rename to
.tslater without changing behavior.
I stop pretending JSDoc is enough when:
- Multiple packages share complex domain types.
- I need declaration emit (
d.ts) for external consumers. - The annotations are longer than the functions.
At that point the "types without a compiler" slogan stops being true. You already have a compiler in your editor, you are just declining to store the types in a dedicated syntax.
The TypeScript migration post puts JSDoc and TypeScript side by side: where each wins, and the migration triggers I actually trust instead of blanket "always use TypeScript" advice.