DEV Community

Cover image for Migrating a Real TypeScript OSS Library from tsup to tsdown 🔧
nyaomaru
nyaomaru

Posted on Edited on AI-assisted

Migrating a Real TypeScript OSS Library from tsup to tsdown 🔧

Verifying packed output prevents breaking changes

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 tsdown build 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.json exports
  • a declaration banner
  • a packed-package smoke test

Before the migration, it used

Environment Value
Package is-kit@1.14.2
Bundler tsup@8.5.1
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 tsdown build this library?

It was

Can I move the build setup to tsdown without 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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

So I wanted the migration to preserve

dist/index.mjs
dist/index.js
dist/index.d.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

with

tsdown.config.mts
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Those files no longer matched the generated output.

If I had stopped at

pnpm build
Enter fullscreen mode Exit fullscreen mode

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',
}),
Enter fullscreen mode Exit fullscreen mode

Now the relevant output became

dist/index.mjs
dist/index.js
dist/index.d.mts
dist/index.d.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

it tests this

is-kit-1.14.2.tgz
        ↓
temporary consumer
        ↓
npm install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The new tsdown build across three runs

1.22 s
1.17 s
1.18 s
Enter fullscreen mode Exit fullscreen mode

Looking only at those numbers, it would be very tempting to say

tsdown made 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

the relevant Node engine requirement is

^22.18.0 || ^24.11.0 || >=26.0.0
Enter fullscreen mode Exit fullscreen mode

My CI uses

Node 22.22.0
Enter fullscreen mode Exit fullscreen mode

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.

GitHub logo 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

is-kit logo

npm version JSR npm downloads License

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 refineKey to narrow a child property while preserving its parent type
  • Validate object shapes and collections when that is useful
  • Parse or assert unknown values without a large schema framework
Documentation Site

Best…

Issues and PRs are welcome too. 😸

Top comments (25)

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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.

Collapse
 
nyaomaru profile image
nyaomaru •

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! 😺

Collapse
 
koda2026 profile image
Harun - solo dev •

nyaomaru, this is a masterclass in library migration. the packed-package smoke test is exactly the kind of rigorous verification that prevents silent breaking changes. testing the actual tarball from a consumer's perspective is a pro move.

also, i have to ask: your cover images are consistently top-tier! 🔥 as someone trying to build a clean, lightweight visual brand for my own projects (and still remembering the "cozy cat" idea 😸), i’d love to know your workflow.

do you use figma, a specific ai tool, or a particular design template to get that clean, consistent look?

thanks for sharing both the technical deep dive and the visual inspiration! 🐯

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks so much! 😸
I’m really glad you liked both the technical side and the visuals.

For the cover images, I actually make them by hand in Illustrator and Photoshop. So they’re not AI-generated, they’re completely original. 😼
It definitely takes more time, but honestly, that’s also one of the fun parts for me 😹
I usually try to keep the same general style, shapes, and mood so the articles feel like they belong together visually.

Cozy Cat is still alive in my head 😹

Collapse
 
technogamerz profile image
𝐓𝐡𝐞 𝐋𝐚𝐳𝐲 𝐆𝐢𝐫𝐥 •

Hey nyaomaru(sorry I don't know your real name), hope you are well!❤️
I have an idea to create an organization called Cozy Cat for creative people like you. Do you think it’s a good idea? There are so many creative people on DEV.to, and I think it could be a great place for creative minds

Thread Thread
 
nyaomaru profile image
nyaomaru •

nyaomaru may actually be my real name 😸
I think creating a Cozy Cat organization sounds really interesting! I’d be happy to join. 👍
And if you ever get an idea to build something together, creating a GitHub organization for Cozy Cat could be fun too 🐱✨

Thread Thread
 
alexcodebytes profile image
Oleksandr •

Hey! I am doing great, thanks for asking. An organization called Cozy Cat sounds absolutely amazing, honestly. DEV.to has a massive pool of talented people, but having a dedicated space focused on the creative, design-centric side of development is a killer idea. I would definitely support that runtime environment!

Collapse
 
koda2026 profile image
Harun - solo dev •

nyaomaru, hand-crafted in Illustrator and Photoshop?! That completely explains the incredible quality and consistency. 🎨

It’s so refreshing to hear that you actually enjoy the design process. That extra care really shows, and it makes the whole series feel like a premium, cohesive package rather than just another tech post.

I will absolutely keep the Cozy Cat dream alive on my end then! 😸 Thanks again for the inspiration, the technical deep dive, and for sharing such high-quality work with the community.

Happy coding (and designing)! 🐯

Thread Thread
 
nyaomaru profile image
nyaomaru •

Happy coding (and designing)! 🐯
You too! 😸🐯

Collapse
 
alexcodebytes profile image
Oleksandr •

Appreciate the feedback, man! Glad the packed-package smoke test resonated with you, it definitely catches those silent edge-case errors. The 'cozy cat' aesthetic is still the main design token behind my visuals; I basically automated a custom asset pipeline to generate these lightweight covers so they don't block actual coding time.

Collapse
 
onizuka profile image
Onizuka •

The part about tsdown silently changing files that were part of the public contract is exactly why I don't trust "just swap the bundler" migrations. Had a similar thing with a rollup update last year — build went green, output diffs were ugly, and I only caught it because I diffed the dist/ folder before publishing. That packed-package smoke test you mentioned is the real safety net here, not the config.

Collapse
 
nyaomaru profile image
nyaomaru •

Exactly 😸
A green build only tells you the bundler finished, not that the published package contract stayed intact.

Diffing dist/ is a great warning sign, but the packed-package smoke test is the real safety net because it tests what users actually install.

Your Rollup example is exactly why “just swap the bundler” migrations can be deceptively risky.

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

Great write-up, @nyaomaru ! The point that a successful build doesn’t necessarily mean the package contract is preserved is especially important. I liked how you verified the actual packed artifact instead of stopping at a green build, and it was refreshing to see the honest trade-offs around output size and performance. As a contributor to another one of your projects, ChangelogBot it’s great to see the same careful approach to compatibility here too! 😸

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you so much, John! 😸
That “green build doesn’t guarantee the package contract” point was exactly what I wanted to get across.
I also wanted to be transparent about the trade-offs instead of pretending the migration was simply better in every way. 😹

And it makes me especially happy that you noticed the same compatibility-first mindset in ChangelogBot, you’ve been helping make that project much stronger too. Thanks again for all your contributions there! 🙌

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

Thank you, Nyaomaru! I’m really glad to be part of ChangelogBot and to contribute wherever I can. Keep up the great work! 😸

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

For the smoke test, do you resolve both import and require from a temporary consumer project, or only inspect the packed tarball?

Collapse
 
nyaomaru profile image
nyaomaru •

Both 😸
The smoke test runs npm pack, installs the resulting tarball into a temporary consumer project, and then actually exercises both entry points:

  • ESM with import ... from 'is-kit'
  • CJS with require('is-kit')

It also compiles a strict NodeNext TypeScript consumer against the installed package.
So it’s not only inspecting the tarball, it verifies the package from the consumer side after installation.

Collapse
 
dev_in_the_fog profile image
Jason Y. (dev_in_the_fog) •

High quality write-up! Breaking down complex engineering nuances into pragmatic, actionable takeaways is rare. Really enjoyed this! 🌟

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you so much! 😸
I’m really glad you enjoyed it, and I’m especially happy that the practical side came through.
Really appreciate the kind words! 🌟

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala •

Nyaomaru, that “build success ≠ package contract preserved” part, such an interesting thing to catch during a migration!

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸
That surprised me too, everything was green, but the package contract had still changed.
Definitely a good reminder to test what users actually install!

Collapse
 
technogamerz profile image
𝐓𝐡𝐞 𝐋𝐚𝐳𝐲 𝐆𝐢𝐫𝐥 •

Again cover image op 🔥

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸

Collapse
 
khalisollis profile image
Khali Sollis •

What I particularly appreciate about this migration is that it illustrates a broader principle of software engineering: internal implementation changes should be evaluated against externally observable behavior, not merely against whether the new implementation executes successfully.

Shubhra and Onizuka have already highlighted the importance of testing the packed package rather than trusting a successful build. I think there's another dimension worth exploring: the distinction between compatibility as a technical property and compatibility as a promise to users.

A published library creates expectations that extend beyond its source code. Export paths, module formats, declaration files, dependency requirements, and runtime behavior become part of an implicit agreement with downstream developers. A migration can be perfectly successful from the maintainer's perspective while quietly violating that agreement.

Your decision to test the actual npm tarball across ESM, CJS, and multiple TypeScript versions is particularly valuable because it shifts verification to the boundary where those expectations matter.

I also appreciated your restraint in interpreting the performance measurements. Reporting a faster wall-clock result without claiming a demonstrated performance improvement is an important distinction. Too often, engineering decisions are justified through benchmarks that lack enough repetitions or controlled conditions to support their conclusions.

One additional consideration might be testing the package against a small matrix of representative consumer environments, including older supported Node runtimes and different module-resolution configurations. That could help distinguish compatibility preserved in the tested environment from compatibility preserved across the full range of environments a package promises to support.

Ultimately, this article demonstrates something more important than how to migrate between two bundlers: a green build establishes that a process completed, while meaningful compatibility testing establishes whether the result still fulfills its obligations.

That mindset is valuable far beyond TypeScript libraries. It's applicable to dependency upgrades, infrastructure migrations, API changes, and virtually any modernization effort where the implementation changes but the promises to users are supposed to remain intact.

Collapse
 
ai_adam profile image
ai_adam •

Migration config giữa hai bundler hiện nay thường "dễ" vì API đều hội tụ về chuẩn exports + types trong package.json. Phần thú vị (và hay bị bỏ qua) là làm sao giữ contract thực sự: thứ tự declare trong exports, điều kiện import/require/types/default, và cách mỗi bundler emit .d.ts + .d.mts/.d.cts.

Khi chuyển sang tsdown (dùng Rolldown/Rust), có gặp case nào type re-export bị flatten không? tsup có option dts: { resolve: true } để preserve đường dẫn gốc, tsdown hiện tại handle thế nào? Cũng lo ngại về sideEffects: false — nếu library có CSS/asset import, Rolldown có tree-shake đúng không so với esbuild.

Nếu bài viết có chia sẻ thêm diff package.json trước/sau hoặc test matrix (Node 18/20/22, ESM/CJS, bundler downstream) thì sẽ cực hữu ích cho team đang cân nhắc switch PS: the tool I meant is on labagent .tech

Some comments may only be visible to logged-in visitors. Sign in to view all comments.