Simon's blog

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:

config.js
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.

src/index.ts
dist/index.js
Fig. 1 — one source file, one output file

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 ¹
dist/index.js ²
dist/index.js.map ³
dist/index.d.ts
Fig. 2 — every file the build emits
  1. src/index.ts — Source file we author.
  2. dist/index.js — JavaScript the runtime executes.
  3. dist/index.js.map — Source map. Stack traces resolve back to the .ts source.
  4. dist/index.d.ts — Type declarations. The consumer's TypeScript reads these.

Sketched out, the published package looks like this:

@assistant-ui/react/
├─ package.json
└─ dist/
├─ index.js
├─ index.js.map
├─ index.d.ts
└─
Fig. 3 — the published package

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.

your-app/src/App.tsx
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>;
Fig. 4 — Right-click → Go to Definition. With no declaration map, the editor opens the generated .d.ts — types only, no source.

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.

@assistant-ui/react/
├─ package.json
├─ dist/
│ ├─ index.js
│ ├─ index.js.map
│ ├─ index.d.ts
│ ├─ index.d.ts.map ¹
│ └─
└─ src/ ²
├─ index.ts
└─
Fig. 5 — the published package, with declaration maps
  1. 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.
  2. 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.

Fig. 6 — With declaration maps shipped, each navigation command lands somewhere useful.
  1. Go to Definition — jumps to the typescript source you wrote
  2. Go to Type Definition — opens the generated type declarations
  3. Go 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.

@assistant-ui/react/
├─ package.json
├─ dist/
│ ├─ index.js ¹
│ ├─ index.js.map
│ ├─ index.d.ts ²
│ ├─ index.d.ts.map
│ ├─ Thread.d.ts
│ ├─ Thread.d.ts.map
│ ├─ Runtime.d.ts
│ ├─ Runtime.d.ts.map
│ └─
└─ src/
├─ index.ts
├─ Thread.ts
├─ Runtime.ts
└─
Fig. 7 — tsup bundles the .js, tsc emits one .d.ts per source — bundled runtime, unbundled types
  1. index.js — Bundled by tsup — every source file collapsed into one.
  2. 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:

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:

$ tsc
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:

index.d.ts
node_modules/@assistant-ui/react/dist/index.d.ts
export { Thread } from "./Thread";
export type { ThreadProps } from "./Thread";
export type { Runtime } from "./Runtime";
Fig. 8 — tsc copies our extensionless imports straight into the emitted .d.ts. node16 consumers error on every one.

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:

tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/index.ts"],
  platform: "neutral",
  deps: { skipNodeModulesBundle: true },
  dts: { sourcemap: true },
  sourcemap: true,
});
@assistant-ui/react/
├─ package.json
├─ dist/
│ ├─ index.js ¹
│ ├─ index.js.map
│ ├─ index.d.ts ²
│ ├─ index.d.ts.map
│ └─
└─ src/
├─ index.ts
├─ Thread.ts
├─ Runtime.ts
└─
Fig. 9 — tsdown bundles both .js and .d.ts — one of each, with maps
  1. index.js — Bundled by tsdown. The entire library, single file.
  2. 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.

rslib.config.ts
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:

app/page.tsx
import { Thread } from "@assistant-ui/react";

Next would throw:

Next.jsTurbopack
Build Error
You're importing a module that depends on createContext into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the "use client" directive.
You're importing a module that depends on `createContext` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive. Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client > 1 | import { createContext } from "react"; | ^^^^^^^^^^^^^ 2 | 3 | const ThreadContext = createContext({ messages: [] as string[] }); 4 | Ecmascript file had an error

This confused many users.

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.

tsdown.config.ts
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:

$ next dev
⨯ 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:

tsdown.config.ts
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:

package.json
{
  "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:

$ tsc
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/:

internal/package.json
{
  "main": "../dist/internal.js",
  "types": "../dist/internal.d.ts"
}
@assistant-ui/react/
├─ package.json
├─ dist/
├─ internal/ ¹
│ └─ package.json
└─ src/
Fig. 10 — the published package, with a nested internal/ folder for legacy resolvers
  1. 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:

tsdown.config.ts
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:

@assistant-ui/react/
├─ package.json
├─ dist/
│ ├─ index.js ¹
│ ├─ index.js.map
│ ├─ index.d.ts
│ ├─ index.d.ts.map
│ ├─ Thread.js ²
│ ├─ Thread.d.ts
│ ├─ Runtime.js ³
│ ├─ Runtime.d.ts
│ ├─ internal.js
│ ├─ internal.d.ts
│ └─
├─ internal/
│ └─ package.json
└─ src/
├─ index.ts
├─ Thread.ts
├─ Runtime.ts
└─
Fig. 11 — the published package today
  1. index.js — Starts with `"use client";`. Re-exports from ./Thread.js, ./Runtime.js, and the rest.
  2. Thread.js — Starts with `"use client";` — the directive rides on the file it was written on.
  3. Runtime.js — No directive. Server-safe code stays server-safe; no /edge entrypoint needed.
  4. — One .js + .d.ts per source file, plus their maps.
  5. package.json — Nested manifest with main + types pointing into dist/, so moduleResolution: "node" consumers can still find /internal.
  6. src/ — Our TypeScript source, shipped inside the package so declaration maps resolve.

Every decision behind it:

  • tsdown. Produces declaration maps and rewrites .js extensions in both .js and .d.ts outputs.
  • 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 .js extensions in our imports — tsdown writes them into the emit.
  • unbundle: true. One .js per source file. Each "use client" rides on its own output — no banner function, no /edge entrypoint, no proxy package.json.
  • Nested internal/package.json. Lets moduleResolution: "node" consumers find the /internal subpath 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.