Compiling .ts to .js
May 27, 2026
assistant-ui gets about 2.5M downloads a month. The pipeline that produces those packages is, in principle, not interesting: You have .ts files, you want .js files. Over the last two years, we hit a series of roadblocks. These are individually small and collectively a memo. Two threads run through it: deciding what to ship, and finding a build tool that could ship it.
Module format
assistant-ui has shipped ESM-only since May 2025.
For years, the JavaScript ecosystem published every package twice: once as CommonJS, once as ESM. It’s a silly ritual — two builds of nearly identical code that differ only in module syntax.
A large part of the ecosystem still writes or compiles code to CommonJS. Up until 2025, projects targeting CommonJS could not use libraries that shipped ESM.
To understand why, let’s take a look at top-level await. Normally await lives inside an async function. Top-level await lets a module pause while it’s being evaluated:
const config = await fetchConfig();
export { config }; To support top-level await, ESM module evaluation is asynchronous.
require(), by contrast, is a synchronous function. Nobody could figure out how to handle top-level await inside require(). The Node team didn’t ship a fix. TC39 didn’t propose one. The bundlers didn’t paper over it. So library authors shipped two copies of every package to npm.
This went on for five years.
This was absurd, because no library actually uses top-level await.
In 2024, Joyee Cheung found the solution: optimistically try to load the ESM module, and if it has top-level await, throw an error.
require(esm) shipped in Node 23.0 (October 2024), 22.12 (December 2024), and 20.19 (March 2025). Today, CommonJS consumers can load an ESM package directly.
The files
We write src/index.ts. The build tool turns it into dist/index.js. The file ends up in someone else’s node_modules/, gets pulled into their bundle, runs in their browser tab.
Next to the .js, we want two more files:
- Source maps (
.js.map) — for readable stack traces when things go wrong - Type declarations (
.d.ts) — what the consumer’s editor and typechecker read
src/index.ts— Source file we author.dist/index.js— JavaScript the runtime executes.dist/index.js.map— Source map. Stack traces resolve back to the .ts source.dist/index.d.ts— Type declarations. The consumer's TypeScript reads these.
Sketched out, the published package looks like this:
There’s one more file we ship that most libraries don’t.
Declaration maps
Cmd-click an export from an npm library and you expect to land somewhere useful. Instead, you land in the emitted .d.ts: type declarations, no source code.
import { Thread } from "@assistant-ui/react";
export default function App() {
return <Thread runtime={runtime} />;
} // this file only contains declarations, not very helpful
export declare interface Runtime {}
export declare type ThreadProps = { runtime: Runtime };
export declare const Thread: React.FC<ThreadProps>; VSCode does ship a “Go to Source Definition” command that jumps to the .js instead, but it has no default shortcut, so nobody finds it. As library authors, we want things to work with the defaults.
The fix is a declaration map: a .d.ts.map next to each .d.ts, pointing back at the source. We also have to ship src/ inside the package, so the .ts it points to actually exists on the consumer’s disk.
index.d.ts.map— Declaration map. Tells the editor where each declared name lives in our source — cmd-click follows it past the .d.ts into the real .ts.src/— Our TypeScript source, shipped inside the package. The declaration map points here, so the .ts file actually exists on the consumer's disk.
With both in place, every navigation command in the consumer’s editor lands somewhere useful.
Go to Definition— jumps to the typescript source you wroteGo to Type Definition— opens the generated type declarationsGo to Source Definition— opens the compiled javascript output
Total cost: a few kilobytes of source code in node_modules. Zero runtime impact.
Building declaration maps
Declaration maps sound great. We wondered why so few libraries shipped them. Turns out: they’re hard to build.
tsup
We tried tsup first. It’s the most popular library bundler in the TypeScript ecosystem. Unfortunately, tsup does not support declaration maps.
Their docs point you to use tsc to generate them. So we tried tsc.
tsup + tsc
We followed tsup’s instructions to use tsc --emitDeclarationOnly --declaration together with tsup.
tsc does not bundle, that didn’t really matter to us. We had bundled runtime, unbundled types.
index.js— Bundled by tsup — every source file collapsed into one.index.d.ts— Emitted by tsc, per source file. tsc has no --bundle, so declarations stay unbundled even though the .js next to them is bundled.
This worked great on our machine, so we shipped it. A month later, someone filed a bug:
Um?
I had to jump on a Google Meet with the user. This was their tsconfig.json:
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true
}
} Almost every project has skipLibCheck: true. Temporarily flipping it to false produces a more helpful error here:
node_modules/@assistant-ui/react/dist/index.d.ts(1,30): error TS2835:
Relative import paths need explicit file extensions in ECMAScript
imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './Thread.js'?
(40+ more) A look at our index.d.ts shows the problem:
export { Thread } from "./Thread";
export type { ThreadProps } from "./Thread";
export type { Runtime } from "./Runtime"; The above index.d.ts works for 99% of our users who are on moduleResolution: "bundler". For a tiny fraction of our users using node16/nodenext, it silently fails with any.
Under nodenext, the correct way to import a .d.ts file in the same folder is via the .js extension. The import above should have been written as ./Thread.js.
Thread.js does not exist anywhere in our src or dist folders.
The only way to make tsc emit correct .d.ts is to set "moduleResolution": "node16" and write .js extensions throughout your source. I tried; my AI agents and I both hated it. So we wrote a tsc transformer that patches the extensions back into the emit. That was our setup for two years.
tsdown
tsdown gets us what we want with a small configuration file:
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
platform: "neutral",
deps: { skipNodeModulesBundle: true },
dts: { sourcemap: true },
sourcemap: true,
}); index.js— Bundled by tsdown. The entire library, single file.index.d.ts— Bundled declarations — every type from every source file, one file.
Honorable mention: rslib
We also tried rslib (thanks to @zmzlois doing the digging).
With bundle: false, it can produce declaration maps. By default, it comes with a footgun: rslib only rewrites extensions in your .js files but silently leaves .d.ts files alone, leading to the same output as tsup + tsc configuration above. Setting redirect.dts.extension: true fixes it.
import { defineConfig } from "@rslib/core";
export default defineConfig({
source: {
entry: {
index: ["./src/**/*.ts", "!./src/**/*.test.ts", "!./src/**/*.spec.ts"],
},
},
lib: [
{
format: "esm",
bundle: false,
dts: true,
redirect: { dts: { extension: true } },
},
],
}); Next.js compatibility
Most of our components are client components. In a Next.js app, if you import them in a server component:
import { Thread } from "@assistant-ui/react"; Next would throw:
Unsure about what to do, we looked at the ecosystem. Luckily, we found that the MUI team was facing the same issue. We copied their solution: add a "use client" banner in our library.
import { defineConfig } from "tsdown";
export default defineConfig({
outputOptions: { banner: `"use client";` },
// ...
}); This way, Next.js treats everything in our library as a client module.
Later, we shipped a tool() helper for Next.js route handlers. The whole package was stamped "use client", so users hit:
⨯ Error: Attempted to call tool() from the server but tool is on the client.
It's not possible to invoke a client function from the server, it can only be
rendered as a Component or passed to props of a Client Component.
at POST (app/api/chat/route.ts:4:18) We added an /edge entrypoint re-exporting the server-safe pieces. Route handlers imported from "@assistant-ui/react/edge" and worked. Then users started calling tool() from client components too, and the split path became a guessing game.
MUI had pivoted to not bundling at all by then. We copied them: per-file emit, each source carrying its own directive. Thread.ts keeps its "use client", Runtime.ts stays clean, /edge becomes unnecessary.
In tsdown, this is one flag:
import { defineConfig } from "tsdown";
export default defineConfig({
unbundle: true,
// ...
}); Subpath exports
We added a subpath export, @assistant-ui/react/internal, to share internal APIs across our packages:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./internal": {
"types": "./dist/internal.d.ts",
"default": "./dist/internal.js"
}
}
} A day later, a consumer on moduleResolution: "node" hit this:
error TS2307: Cannot find module '@assistant-ui/react/internal'
or its corresponding type declarations.
There are types at '.../@assistant-ui/react/dist/internal.d.ts', but this
result could not be resolved under your current 'moduleResolution' setting.
Consider updating to 'node16', 'nodenext', or 'bundler'. moduleResolution: "node" models Node.js’s pre-2019 resolution algorithm and ignores the exports field.
The fix, borrowed from Liveblocks: ship a second package.json inside an internal/ folder at the package root, with main pointing back into dist/:
{
"main": "../dist/internal.js",
"types": "../dist/internal.d.ts"
} internal/— Nested folder shipped at the package root. Contains a single package.json with main + types pointing into ../dist/, so moduleResolution: "node" consumers fall back here and resolve correctly.
Where we landed
The build that ships today is:
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
platform: "neutral",
deps: { skipNodeModulesBundle: true },
unbundle: true,
dts: { sourcemap: true },
sourcemap: true,
}); Which produces this on disk:
index.js— Starts with `"use client";`. Re-exports from ./Thread.js, ./Runtime.js, and the rest.Thread.js— Starts with `"use client";` — the directive rides on the file it was written on.Runtime.js— No directive. Server-safe code stays server-safe; no /edge entrypoint needed.…— One .js + .d.ts per source file, plus their maps.package.json— Nested manifest with main + types pointing into dist/, so moduleResolution: "node" consumers can still find /internal.src/— Our TypeScript source, shipped inside the package so declaration maps resolve.
Every decision behind it:
- tsdown. Produces declaration maps and rewrites
.jsextensions in both.jsand.d.tsoutputs. - ESM only. Node ≥20.19 lets CJS
require()ESM; dual builds aren’t needed. - Declaration maps + shipped
src/. Cmd-click lands on our.ts, not the stripped.d.ts. moduleResolution: "bundler"in source. No.jsextensions in our imports — tsdown writes them into the emit.unbundle: true. One.jsper source file. Each"use client"rides on its own output — no banner function, no/edgeentrypoint, no proxypackage.json.- Nested
internal/package.json. LetsmoduleResolution: "node"consumers find the/internalsubpath that their resolver would otherwise ignore.
We had to learn many of these lessons the hard way. I hope your path is shorter than ours.