Compiling .ts to .js

sfarshid1 pts0 comments

Compiling .ts to .js

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();<br>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 ²<br>dist/index.js.map ³<br>dist/index.d.ts ⁴

Fig. 2 — every file the build emits src/index.ts — Source file we author.<br>dist/index.js — JavaScript the runtime executes.<br>dist/index.js.map — Source map. Stack traces resolve back to the .ts source.<br>dist/index.d.ts — Type declarations. The consumer's TypeScript reads these.

Sketched out, the published package looks like this:

@assistant-ui/react/<br>├─ package.json<br>└─ dist/<br>├─ index.js<br>├─ index.js.map<br>├─ index.d.ts<br>└─ …

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.

App.tsx Thread.d.ts ↺<br>your-app/src/App.tsx node_modules/@assistant-ui/react/dist/Thread.d.ts<br>import { Thread } from "@assistant-ui/react";

export default function App() {<br>return Thread runtime={runtime} />;<br>} ▸ Go to Definition Go to Type Definition<br>Go to Source Definition

// this file only contains declarations, not very helpful<br>export declare interface Runtime {}<br>export declare type ThreadProps = { runtime: Runtime };<br>export declare const Thread: React.FCThreadProps>;

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/<br>├─ package.json<br>├─ dist/<br>│ ├─ index.js<br>│ ├─ index.js.map<br>│ ├─ index.d.ts<br>│ ├─ index.d.ts.map ¹<br>│ └─ …<br>└─ src/ ²<br>├─ index.ts<br>└─ …

Fig. 5 — the published package, with declaration maps 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.<br>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 → src/Thread.ts ¹

▸ Go to Type Definition → dist/Thread.d.ts ²

▸ Go to Source Definition → dist/Thread.js ³

Fig. 6 — With declaration maps shipped, each navigation command lands somewhere useful. Go to Definition — jumps to the typescript source you wrote<br>Go to Type Definition — opens the generated type declarations<br>Go to Source Definition — opens the compiled javascript...

index source dist definition package await

Related Articles