Types without the build step: why I chose JSDoc + tsc over TypeScript files

typescript nodejs bun javascript
JSDoc type annotations checked by the TypeScript compiler with no build step

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

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.

Recommended

How do you connect a simple front end to a typed API? @jameslcowan typescript, javascript
What is TypeScript, and why do product teams still adopt it? @jameslcowan typescript, javascript
We moved a production API from Node to Bun in an afternoon @jameslcowan bun, javascript

Recommended

How do you connect a simple front end to a typed API? @jameslcowan typescript, javascript
What is TypeScript, and why do product teams still adopt it? @jameslcowan typescript, javascript
We moved a production API from Node to Bun in an afternoon @jameslcowan bun, javascript

Search by title, tag, description, or the prose itself. Results appear as you type.