Types without the build step: why I chose JSDoc + tsc over TypeScript files
My platform runs on a rule that sounds almost naive: deploying means copying files. No bundler, no transpiler, no dist/ folder of artifacts that might drift from source, no toolchain version that rots between deploys. The file on the server is the file in the editor. Debugging a production issue means reading the code that's actually running, and after years of source maps lying to me at the worst moments, I price that very high.
TypeScript's checking is worth real money on any codebase that outlives a weekend. TypeScript's build step would break my rule. So I kept the checker and skipped the compile: plain JavaScript files, JSDoc annotations, and tsc running with checkJs and noEmit. This post is where that line sits on my platform, what it costs, and when I'd cross over to real .ts files instead.
checkJs is the same checker, pointed at comments
The part most people miss is that this isn't a weaker knockoff of TypeScript. With checkJs, tsc runs its full inference and checking engine over .js files, reading types from JSDoc comments where you declare them and inferring everywhere else. Wrong argument types, misspelled properties, null slips into non-null positions: the same squiggles, the same CI failure.
The services behind my owner console, the stack from Replacing the SaaS bill, are written this way. The funnel and reliability modules carry @param and @returns annotations on every exported function, so the shape of an options object like { siteId, windowDays, now } is checked at every call site:
/**
* @param {string} siteId
* @param {{ windowDays?: number, now?: number }} opts
* @returns {{ uptimePct: number, incidents: number }}
*/
export function buildReliability(siteId, opts = {}) {
// ...
}
Node runs that file as-is. The checker verifies it with zero emitted output, so the deploy path stays copy-only and the runtime never waits on a compile.
Annotate the boundaries, let the locals infer
Typing everything in JSDoc would be miserable, so I don't. The discipline that works: exported and shared surfaces get explicit annotations, and everything inside a function body is left to inference.
Boundaries are where type errors actually cost you, because that's where one module's assumption meets another's. A @typedef for each shape that crosses a module line, @param and @returns on each exported function, and the checker propagates the rest. Locals almost never need a comment; const rows = db.prepare(sql).all() gets typed by what flows into it, same as in a .ts file. My rough ratio is one comment block per exported function and near zero annotations inside bodies, which keeps the overhead close to what writing signatures in TypeScript costs anyway.
The disagreeable version, which I hold: for a small team on plain Node services, .ts files buy you almost nothing over this setup, and they charge you a build pipeline for it. Most codebases that "need TypeScript" need typed boundaries and would be fine with typed comments.
The tradeoffs, honestly
JSDoc's syntax gets clumsy exactly where TypeScript's gets elegant. Generics are the sore spot: a generic function is @template T plus type expressions squeezed into comment braces, and something like a mapped type or a conditional type turns into line noise you'll dread editing. When I need a genuinely complex type, I sometimes define it in a .d.ts file and import it from JSDoc, which works but admits the comment syntax lost.
Inference has gaps too. Casting requires the awkward /** @type {Foo} */ (value) parenthesized form, narrowing occasionally needs a nudge a .ts file wouldn't, and third-party types are only as good as the package's shipped declarations. And the checker is only honest if it runs on every change; typed comments nobody checks are documentation, and documentation drifts.
None of that has outweighed the missing build step for me, but I'm counting a specific codebase: small modules, boring types, one owner. The calculus shifts with scale.
Where I use real TypeScript anyway
The buildless rule is per surface, not a religion. One app on this same platform is a Vite-built frontend written in actual .ts, with tsc --noEmit as its verify gate, because it already needs a build step for other reasons. Once you're paying for a compile anyway, JSDoc's syntax tax stops buying you anything, so use the nicer syntax. That's the general shape of when .ts wins: bigger teams that want types to be unavoidable rather than conventional, published libraries where consumers expect declarations, and any surface where a bundler already sits in the path. My broader take on when the language earns its keep is in What is TypeScript for small teams.
There's also a runtime answer coming for this whole tradeoff. Bun executes TypeScript natively, no compile step, which would let a platform keep the copy-files deploy and still write .ts directly. I compared the runtimes in What is Bun vs Node, and this platform is actively evaluating that move. If it lands, the honest conclusion is that JSDoc was the right bridge, and the bridge gets shorter.
Takeaways
- Buildless deploys are a feature: the code in production is the code in git, and no toolchain sits between them.
tscwithcheckJsandnoEmitruns the real checker on plain.js; you give up far less than the file extension suggests.- Annotate exported and shared boundaries, let locals infer. Boundaries are where the errors that matter live.
- Budget for clumsy generics and the odd cast. If your types are exotic, the comment syntax will hurt.
- Choose
.tsfor bigger teams, published libraries, or any surface that builds anyway, and watch Bun: a runtime that executes TypeScript makes this whole tradeoff dissolve.
Work with Kleto
I am James Cowan, a product engineer and the founder of Kleto. Kleto is a product engineering agency that ships production software from strategy through handoff. Pragmatic type strategy on real deploy constraints is exactly the kind of decision we help teams make and implement. If that matches your stack, contact Kleto and we will scope a sensible first step.