How does it work?

January 30, 2026 · View on GitHub

This project abuses the internals implementation of rollup in a quite interesting way. See, rollup uses string manipulation to generate its output file, by changing and removing parts of the input file content via MagicString. It also does quite extensive dead code elimination by walking the AST of the input code and figuring out which parts it can safely remove from the output bundle.

We can use this knowledge to specifically direct rollup to keep and remove parts of our input file, and to rename the correct Identifiers.

What we do, is to transform the Typescript code into a virtual AST, that is in itself just really strange code, but it makes rollup do what we would like it to do.

Creating declarations

For each export (class, function, interface or type), we will create a bogus FunctionDeclaration for rollup. The trick here is to annotate this FunctionDeclaration with a certain start and end. Rollup will then just remove all the bytes between start and end, without even looking into what those bytes actually are, if it figures out that the declaration is not referenced.

function foo() {}
export function bar() {}

See it live

Creating side-effects

Rollup will actually analyze functions for side-effects and happily remove functions which are side-effect free, even though they are referenced in other parts of your code.

In order for rollup to at least consider putting a function into our bundle, we have to introduce a side-effect into the function. How do we do that? The answer is to generate code that rollup can not see inside. For example by calling an unreferenced identifier. That identifier could potentially live in window and rollup does not know that. So it does not touch that code.

_()

See it live

Creating references

If someone has looked very carefully at the previous example, you will see that rollup actually inserts a semicolon after the CallExpression. This one took me a long time to figure out and work around.

In the end I decided to create references between different declarations as function argument defaults. That way rollup will not insert semicolons that would otherwise mess with out TypeScript code.

Again, all the Identifiers are annotated with correct start and end markers. So if rollup decides to rename them, it will touch the correct parts of the code. Also, the function name itself is part of the identifier list, because there might be identifiers before the function name, such as type parameters and maybe things we would want to remove.

function foo(_0 = foo) {}
function bar(_0 = bar, _1 = foo) {}
function baz(_0 = baz) {}
export function foobar(_0 = foobar, _1 = bar, _2 = baz) {}

See it live

Removing nested code

Building on the previous example, we can use the list of function argument defaults to, and the thing we learned before about removing top-level code to mark nested code for deletion.

For this case, we create an arrow function with some dead code inside. As you will see in the example, rollup will remove that code. Again, annotating it with start and end markers and you are done.

function foo(_0 = foo, _1 = () => {removeme}) {}
export function bar(_0 = bar, _1 = foo) {}

See it live

With that, we have all the tools to create roll-upd .d.ts files.

Sourcemap challenges

The virtual AST trick creates a problem for sourcemaps: Go to Definition breaks.

TypeScript's declaration maps (.d.ts.map) have per-token granularity—each identifier like User, id, name gets its own mapping. This enables "Go to Definition" to jump to the exact identifier, not just the line.

But Rollup's bundle sourcemap is sparse. It only has mappings at declaration boundaries, not per-identifier. Why? Our virtual AST only has FunctionDeclaration nodes with start/end markers—no token-level detail for Rollup to preserve.

Why can't MagicString/Rollup handle this?

They do handle sourcemaps properly—the bottleneck is in Rollup's bundle map generation.

MagicString works fine; we use hires: true in our transform hook. Rollup properly composes the chain via collapseSourcemaps. The problem is that Rollup generates the bundle map with hires: false by default, and there's no output option to change this.

Even if Rollup supported hires: true, it wouldn't help—our virtual AST doesn't have real token positions, just FunctionDeclaration boundaries.

Why standard remapping fails

You might think @jridgewell/remapping could compose Rollup's map with the input .d.ts.map after the fact. It can't add detail back.

Remapping traces segments—for each segment in the outer map, it looks up where that position maps in the inner map. It cannot add segments that don't exist in the outer map.

Rollup's sparse map:     6 segments  (outer)
Input .d.ts.map:        17 segments  (inner)
Remapped result:         3 segments  (fewer, not more!)

The outer map's granularity is the ceiling. Remapping can only lose detail, never gain it.

Sparse-anchor hydration

Instead of remapping, we use Rollup's sparse map as "anchors" to find which source line each output line came from, then copy the detailed segments from the input map:

For each output line:
  1. Find first mapped segment in Rollup's map (the "anchor")
  2. Extract the source line number from the anchor
  3. Copy ALL segments from that source line in the input map
  4. Adjust columns by delta: anchorOutputCol - anchorSourceCol

This works because TypeScript preserves line structure—declarations stay on their original lines through bundling. The anchor tells us "this output line came from source line N", and we know the input map has detailed per-identifier mappings for line N.

We defer sourcemap processing to generateBundle rather than transform. Even if we composed maps in transform, Rollup's bundling would still produce a sparse output map. Plus, options.sourcemap is an output option—not available in transform. By deferring, we also skip loading input sourcemaps entirely when sourcemaps are disabled.

Input paths

When dts({ sourcemap: true }) is set, the plugin handles two input types:

  • .d.ts files: External .d.ts.map files are loaded from disk
  • .ts files: TypeScript's in-memory declaration map is captured during program.emit()

Without this option, sourcemaps remain sparse (basic line-level mappings from MagicString).

For .ts inputs, we strip //# sourceMappingURL comments from emitted declarations. TypeScript emits external map references, but we pass the map directly via inputMapText. Without stripping, Rollup would double-process the sourcemap.

Implementation quirks

sourcesContent rejection: tsserver rejects sourcemaps containing sourcesContent, silently falling back to no mapping:

if (map.sourcesContent && map.sourcesContent.some(isString)) return undefined;

We strip sourcesContent entirely from output maps to stay compatible.

URL sourceRoot: TypeScript's sourceRoot can be a URL like https://github.com/org/repo/blob/main/src/. We detect URLs via the :// pattern (not just :, which would match Windows drive letters) and use the URL constructor for proper path resolution—new URL("../../src/index.ts", "https://example.com/dist/types/") correctly produces https://example.com/src/index.ts.

Libraries