The config migration was small. Preserving the package contract was the interesting part.
Hoi hoi! π
I'm @nyaomaru, a frontend engineer who enjoyed looking for fungus this season. πΈπ
Recently, I migrated its build setup from tsup to tsdown.
At first, I expected this to be a very small task.
Just remove tsup.
Just install tsdown.
Just change the config.
Run the build.
Done! π
And honestly...
The config migration was small.
But there was one interesting problem.
The first
tsdownbuild succeeded while silently changing files that were already part of the package's public contract.
So this article isn't really an introduction to tsdown.
Instead, I want to show what happened when I migrated a real published TypeScript library from tsup to tsdown, what actually changed, and how I verified that consumers would still receive the same package.
Let's take a look! π
π€ Why Migrate a Package That Already Builds Correctly?
is-kit is a zero-dependency TypeScript library for runtime type guards.
Its build setup was already pretty boring.
And boring build systems are good. πΈ
The package had:
- one entry point
- ESM and CJS outputs
- bundled declaration files
- explicit
package.jsonexports - a declaration banner
- a packed-package smoke test
Before the migration, it used
| Environment | Value |
|---|---|
| Package | [email protected] |
| Bundler | [email protected] |
| Entry | src/index.ts |
| Output | ESM + CJS + bundled declarations |
| Target | esnext |
I wasn't trying to solve a broken build.
The motivation was mostly maintenance.
tsdown is built around Rolldown, has an active ecosystem, and is explicitly designed as a migration path for projects currently using tsup.
So the question wasn't
Can
tsdownbuild this library?
It was
Can I move the build setup to
tsdownwithout changing what existing consumers receive?
That's a much more useful question for a published package.
π¦ The Package Contract I Needed to Preserve
For an application, changing an output filename may not matter much.
For a library, it can be a breaking change.
is-kit already exposes files through explicit package exports.
The important paths were effectively
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
So I wanted the migration to preserve
dist/index.mjs
dist/index.js
dist/index.d.ts
along with the existing ESM/CJS runtime behavior, declaration compatibility, export set, and declaration banner.
In other words
The source code was not the contract here. The packed npm package was.
That distinction became important very quickly.
π οΈ The Migration Looked Almost Trivial
I replaced tsup with tsdown and replaced
tsup.config.ts
with
tsdown.config.mts
I intentionally used .mts.
The package itself is not "type": "module", and using an ESM-specific config extension avoids having Node reinterpret the config file and emit related warnings.
Most of the important options mapped almost directly.
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
outDir: "dist",
target: "esnext",
banner: {
dts: dtsBanner,
},
});
The declaration banner also stayed dts-only with banner: { dts: dtsBanner }.
So far, everything looked easy.
Then I ran the build.
It passed.
And the package contract was wrong. π
π₯ The First Build Succeeded and Changed My Filenames
This was the interesting part.
Before the migration, the important generated files looked like this:
| Build | Main generated files |
|---|---|
tsup |
index.mjs, index.js, index.d.ts
|
default tsdown migration |
index.mjs, index.cjs, index.d.mts, index.d.cts
|
The tsdown build completed successfully with exit code 0.
But my existing package.json still expected
require β ./dist/index.js
types β ./dist/index.d.ts
Those files no longer matched the generated output.
If I had stopped at
pnpm build
and published the package.
CJS consumers and TypeScript resolution could have been broken.
This was the most important lesson from the migration
Build success β package contract preserved.
A bundler only knows whether it successfully produced its output.
It doesn't automatically mean that output still matches every promise your already-published package makes.
π§ Fixing the Public Contract with outExtensions
The tsdown migration guide explicitly calls out the rename from outExtension to outExtensions.
For is-kit, I used it to preserve the existing filenames
outExtensions: ({ format }) => ({
dts: format === 'cjs' ? '.d.ts' : '.d.mts',
js: format === 'cjs' ? '.js' : '.mjs',
}),
Now the relevant output became
dist/index.mjs
dist/index.js
dist/index.d.mts
dist/index.d.ts
and the existing exports continued to resolve correctly.
I don't really consider the original output behavior a tsdown bug.
tsdown chooses extensions based on the package type and output format to avoid ambiguous module interpretation.
That's reasonable.
But for an existing package, reasonable new defaults are still changes.
If consumers already depend on your filenames, those filenames are part of your compatibility surface.
π§ͺ Verifying the Published Package with a Smoke Test
This is where an existing packed-package smoke test in is-kit helped a lot.
I already had a pnpm test:package command that builds the package, runs npm pack, installs the generated tarball into a temporary project, and verifies it from a real consumer's perspective.
Instead of testing this
src/index.ts
it tests this
is-kit-1.14.2.tgz
β
temporary consumer
β
npm install
A library can work perfectly inside its own repository while still publishing a broken package, so this smoke test exercises the artifact that users would actually install.
After the migration, I expanded the test to verify more of the package contract π
| Contract | Result |
|---|---|
exports["."].import β ./dist/index.mjs
|
β pass |
exports["."].require β ./dist/index.js
|
β pass |
exports["."].types β ./dist/index.d.ts
|
β pass |
| Runtime dependencies | β 0 |
| ESM exports | β same 83 exports |
| CJS exports | β same 83 exports |
| Declaration banner | β preserved |
| ESM runtime import | β pass |
| CJS runtime require | β pass |
| Packed TypeScript consumer | β pass |
I also installed the packed package into temporary consumer projects using TypeScript v5.7 through v7.0.
All of them successfully resolved and consumed the generated declarations.
This isn't testing whether each TypeScript version can generate declarations through tsdown. It's testing the artifact my users actually install.
So if a future build change accidentally alters an extension, export path, declaration file, or runtime behavior, the smoke test should catch it before publishing.
For a library migration like this, that is much more meaningful than simply asserting "build exited successfully".
π The Output Actually Got Bigger
I was also curious about artifact size.
This produced a result I didn't expect.
| Metric | tsup |
tsdown |
Difference |
|---|---|---|---|
| JS + dts total | 116,503 B | 144,007 B | +23.6% |
| ESM JavaScript | 15,787 B | 29,410 B | +86.3% |
| CJS JavaScript | 19,318 B | 31,155 B | +61.3% |
| dts, one format | 40,699 B | 41,721 B | +2.5% |
npm pack tarball |
37,226 B | 42,366 B | +13.8% |
In this configuration, the tsdown output retained more comments and region markers than the previous tsup output.
So the raw JavaScript became noticeably larger.
Compression reduced the difference in the actual npm tarball, but didn't remove it.
For is-kit, this isn't particularly concerning.
It is a small zero-dependency utility library, and we're talking about a few kilobytes in the final tarball.
But it was still a useful reminder
A faster or newer bundler does not automatically mean a smaller artifact.
If package size is a strict constraint for your library, I would compare the generated files and decide on your minification strategy before migrating.
β±οΈ What About Build Performance?
I also measured wall-clock build time.
The old tsup build
1.50 s
The new tsdown build across three runs
1.22 s
1.17 s
1.18 s
Looking only at those numbers, it would be very tempting to say
tsdownmade the build faster! π
But I don't think this benchmark supports that conclusion.
I only have one measured tsup run in this comparison.
Also, this is a tiny single-entry library where declaration generation represents a large part of the build.
The bundlers' own timing output was roughly
tsup
JavaScript: ~22 ms
dts: ~707 ms
tsdown
complete: ~717β755 ms
The wall-clock result was better, but with only one tsup sample and declaration generation dominating this tiny library, I don't think this is enough evidence to claim a meaningful performance improvement.
But no, I'm not migrating because I saved around 300 ms.
π± Was the Migration Actually Small?
For is-kit, yes.
There were no source-code changes.
The migration was basically limited to
dependency
config
task documentation
package smoke assertions
The important options were almost one-to-one.
But I think it's important to explain why it stayed small.
is-kit has
- one entry
- zero runtime dependencies
- no bundler plugins
- no CSS pipeline
- no complicated code splitting
and, most importantly, it already had a way to test the packed package as a consumer.
The migration becomes more interesting if your package relies on custom plugins, unusual entries, CSS processing, exact source maps, generated exports, strict byte-size limits, or older Node build environments.
There is also a build-environment requirement worth checking.
For the version tested here
tsdown 0.23.0
Rolldown 1.2.8
the relevant Node engine requirement is
^22.18.0 || ^24.11.0 || >=26.0.0
My CI uses
Node 22.22.0
so that was fine.
But if contributors still build your library with Node v20 or an older Node v22 version, this is something you need to solve before migrating.
That doesn't necessarily mean your library consumers must use Node v22.
It's a build-tool requirement.
Still, contributor and CI environments are part of the migration cost too. πΏ
π― So, Was Moving from tsup to tsdown Worth It?
For is-kit, I think yes.
But not because of performance.
The migration preserved the package contract across ESM, CJS, declarations, exports, and the packed-package consumer tests.
The configuration change was small and reviewable.
My existing Node environment satisfies the new build requirement.
And the build setup is now aligned with the actively evolving Rolldown ecosystem.
The cost was also real
- artifact size increased
- Node build requirements increased
- output extensions needed explicit configuration
So I wouldn't describe this as
Everyone using tsup should migrate immediately because tsdown is faster!
That's not what this experiment showed.
For me, the better conclusion is
If your project already meets the Node requirements and you want to move your build setup toward the Rolldown ecosystem, migrating a small library from tsup to tsdown can be a very reasonable change.
But verify the package you publish.
Not only the source.
Not only the config.
And definitely not only the green build message. πΈ
Because the most interesting bug in this migration happened when the build said everything was fine.
If you'd like to see the package from this article in a real project, is-kit is open source.
It's a lightweight, zero-dependency TypeScript type guard library focused on runtime validation and safe narrowing.
If it looks useful for your project, I'd be happy if you gave it a try and a β on GitHub is always appreciated.
nyaomaru
/
is-kit
Build small guards. Compose them. A lightweight, zero-dependency toolkit for building reusable TypeScript type guards that compose, refine, and preserve natural narrowing. Runtime-safe π‘οΈ, composable π§©, and ergonomic β¨.
is-kit
Build small guards. Compose them.
is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.
is-kit is not just a collection of isX helpers. Its main focus is composing
small runtime checks into reusable guards while preserving useful TypeScript
narrowing.
It helps you write small isFoo functions, compose them into richer runtime
checks, refine properties on values you already know about, and keep
TypeScript narrowing natural inside regular control flow. Use it at runtime
boundaries when needed, without requiring a schema-first workflow.
Runtime-safe π‘οΈ, composable π§©, and ergonomic β¨ without asking you to adopt a heavy schema workflow.
- Build and reuse typed guards
-
Compose guards with
and,or,not,oneOf - Use
refineKeyto narrow a child property while preserving its parent type - Validate object shapes and collections when that is useful
-
Parse or assert
unknownvalues without a large schema framework
Bestβ¦
Issues and PRs are welcome too. πΈ

Top comments (3)
Really liked the way you approached this migration, especially testing the packed package rather than stopping at a successful build. A library can have a completely green build and still ship something that no longer matches its
exports, runtime entry points, or declaration paths.Testing the actual tarball from a temporary consumer makes that boundary much clearer than testing the repository build alone.
Thanks! πΈ
That was exactly the point I cared about looking at it from the consumerβs side and checking what actually gets packed and installed, not just whether the build is green.
Really glad you liked that approach! πΊ
Again cover image op π₯