Migrando uma biblioteca OSS real em TypeScript de tsup para tsdown
A migração da configuração foi pequena. Preservar o contrato do pacote foi a parte interessante.
Hoi hoi! 👋
Eu sou @nyaomaru, engenheiro frontend que se divertiu procurando cogumelos nesta temporada. 😸🍄
Recentemente, migrei o setup de build do is-kit de tsup para tsdown.
No começo, achei que seria uma tarefa bem pequena.
Só remover o tsup.
Só instalar o tsdown.
Só alterar a configuração.
Rodar o build.
Pronto! 🎉
E, de fato...
A migração da configuração foi pequena.
Mas apareceu um problema interessante.
O primeiro build com
tsdownterminou com sucesso enquanto alterava silenciosamente arquivos que já faziam parte do contrato público do pacote.
Por isso, este artigo não é exatamente uma introdução ao tsdown.
Quero mostrar o que aconteceu ao migrar uma biblioteca TypeScript real e já publicada de tsup para tsdown, o que realmente mudou e como verifiquei que os consumidores continuariam recebendo o mesmo pacote.
Vamos ver! 👀
🤔 Por que migrar um pacote que já faz build corretamente?
is-kit é uma biblioteca TypeScript sem dependências para type guards em runtime.
O setup de build já era bem sem graça.
E sistemas de build sem graça são bons. 😸
O pacote tinha:
- um único entry point
- saída ESM e CJS
- arquivos de declaração agrupados
exportsexplícitos nopackage.json- um banner nas declarações
- um smoke test do pacote gerado
Antes da migração, ele usava:
| Ambiente | Valor |
|---|---|
| Pacote | is-kit@1.14.2 |
| Bundler | tsup@8.5.1 |
| Entry | src/index.ts |
| Output | ESM + CJS + declarações agrupadas |
| Target | esnext |
Eu não estava tentando corrigir um build quebrado.
A motivação era principalmente manutenção.
O tsdown é construído sobre o Rolldown, possui um ecossistema ativo e foi explicitamente projetado como um caminho de migração para projetos que atualmente usam tsup.
Então a pergunta não era:
O
tsdownconsegue fazer o build desta biblioteca?
Era:
Consigo migrar o setup de build para
tsdownsem alterar o que os consumidores atuais recebem?
Para um pacote já publicado, essa é uma pergunta muito mais útil.
📦 O contrato do pacote que eu precisava preservar
Em uma aplicação, alterar o nome de um arquivo de saída pode não fazer muita diferença.
Em uma biblioteca, isso pode ser uma breaking change.
O is-kit já expõe seus arquivos através de exports explícitos no pacote.
Os caminhos importantes eram, essencialmente:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}
Então eu queria que a migração preservasse:
dist/index.mjs
dist/index.js
dist/index.d.ts
além do comportamento ESM/CJS existente, compatibilidade das declarações, conjunto de exports e banner das declarações.
Em outras palavras:
O código-fonte não era o contrato aqui. O pacote npm empacotado era.
Essa diferença rapidamente se tornou importante.
🛠️ A migração parecia quase trivial
Substituí tsup por tsdown e troquei:
tsup.config.ts
por:
tsdown.config.mts
Usei .mts de propósito.
O pacote em si não possui "type": "module", e usar uma extensão específica para ESM evita que o Node reinterprete o arquivo de configuração e gere warnings relacionados a isso.
A maioria das opções importantes teve uma correspondência quase direta.
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,
},
});
O banner das declarações também continuou aplicado apenas aos arquivos dts com:
banner: { dts: dtsBanner }
Até aqui, tudo parecia fácil.
Então rodei o build.
Passou.
E o contrato do pacote estava errado. 🙀
💥 O primeiro build passou e alterou os nomes dos arquivos
Essa foi a parte interessante.
Antes da migração, os principais arquivos gerados eram:
| Build | Principais arquivos gerados |
|---|---|
tsup | index.mjs, index.js, index.d.ts |
migração padrão para tsdown | index.mjs, index.cjs, index.d.mts, index.d.cts |
O build com tsdown terminou normalmente com exit code 0.
Mas meu package.json existente ainda esperava:
require → ./dist/index.js
types → ./dist/index.d.ts
Esses arquivos já não correspondiam mais ao output gerado.
Se eu tivesse parado em:
pnpm build
e publicado o pacote, consumidores CJS e a resolução do TypeScript poderiam ter sido quebrados.
Essa foi a principal lição da migração:
Build com sucesso ≠ contrato do pacote preservado.
Um bundler sabe apenas se conseguiu produzir seu output.
Isso não significa automaticamente que esse output continua respeitando todas as promessas que seu pacote já publicado faz.
🔧 Corrigindo o contrato público com outExtensions
O guia de migração do tsdown menciona explicitamente a mudança de outExtension para outExtensions.
No is-kit, usei essa opção para preservar os nomes de arquivos existentes:
outExtensions: ({ format }) => ({
dts: format === 'cjs' ? '.d.ts' : '.d.mts',
js: format === 'cjs' ? '.js' : '.mjs',
}),
Agora o output relevante passou a ser:
dist/index.mjs
dist/index.js
dist/index.d.mts
dist/index.d.ts
e os exports existentes continuaram resolvendo corretamente.
Eu não considero o comportamento original do tsdown um bug.
O tsdown escolhe extensões com base no tipo do pacote e no formato de saída para evitar interpretações ambíguas de módulos.
Isso é razoável.
Mas, para um pacote já existente, defaults novos e razoáveis ainda são mudanças.
Se consumidores já dependem dos nomes dos seus arquivos, esses nomes fazem parte da sua superfície de compatibilidade.
🧪 Verificando o pacote publicado com um smoke test
Foi aqui que um smoke test do pacote empacotado que já existia no is-kit ajudou bastante.
Eu já tinha um comando:
pnpm test:package
que faz o build do pacote, executa npm pack, instala o tarball gerado em um projeto temporário e o verifica a partir da perspectiva de um consumidor real.
Em vez de testar isto:
src/index.ts
ele testa isto:
is-kit-1.14.2.tgz
↓
consumidor temporário
↓
npm install
Uma biblioteca pode funcionar perfeitamente dentro do próprio repositório e ainda assim publicar um pacote quebrado.
Por isso, esse smoke test exercita o artefato que os usuários realmente irão instalar.
Depois da migração, ampliei o teste para verificar mais partes do contrato do pacote 👇
| Contrato | Resultado |
|---|---|
exports["."].import → ./dist/index.mjs | ✅ pass |
exports["."].require → ./dist/index.js | ✅ pass |
exports["."].types → ./dist/index.d.ts | ✅ pass |
| Dependências em runtime | ✅ 0 |
| Exports ESM | ✅ mesmos 83 exports |
| Exports CJS | ✅ mesmos 83 exports |
| Banner das declarações | ✅ preservado |
| Import em runtime via ESM | ✅ pass |
require em runtime via CJS | ✅ pass |
| Consumidor TypeScript do pacote | ✅ pass |
Também instalei o pacote empacotado em projetos temporários usando TypeScript v5.7 até v7.0.
Todos conseguiram resolver e consumir as declarações geradas corretamente.
Isso não testa se cada versão do TypeScript consegue gerar declarações através do tsdown.
Testa o artefato que meus usuários realmente instalam.
Portanto, se alguma futura alteração no build mudar acidentalmente uma extensão, um caminho de export, um arquivo de declaração ou algum comportamento em runtime, o smoke test deve detectar isso antes da publicação.
Para uma migração de biblioteca como esta, isso é muito mais significativo do que simplesmente verificar que o "build terminou com sucesso".
📏 O output ficou maior
Também fiquei curioso sobre o tamanho dos artefatos.
E o resultado não foi o que eu esperava.
| Métrica | tsup | tsdown | Diferença |
|---|---|---|---|
| JS + dts total | 116.503 B | 144.007 B | +23,6% |
| JavaScript ESM | 15.787 B | 29.410 B | +86,3% |
| JavaScript CJS | 19.318 B | 31.155 B | +61,3% |
| dts, um formato | 40.699 B | 41.721 B | +2,5% |
tarball do npm pack | 37.226 B | 42.366 B | +13,8% |
Nessa configuração, o output do tsdown manteve mais comentários e region markers do que o output anterior do tsup.
Por isso, o JavaScript bruto ficou consideravelmente maior.
A compressão reduziu a diferença no tarball npm final, mas não a eliminou.
Para o is-kit, isso não me preocupa muito.
É uma pequena biblioteca utilitária sem dependências, e estamos falando de poucos kilobytes no tarball final.
Mas ainda assim foi um bom lembrete:
Um bundler mais novo ou mais rápido não significa automaticamente um artefato menor.
Se o tamanho do pacote for uma restrição importante para sua biblioteca, eu compararia os arquivos gerados e decidiria a estratégia de minificação antes da migração.
⏱️ E quanto à performance do build?
Também medi o tempo total do build.
O antigo build com tsup:
1.50 s
O novo build com tsdown, em três execuções:
1.22 s
1.17 s
1.18 s
Olhando apenas esses números, seria muito tentador dizer:
O
tsdowndeixou o build mais rápido! 🚀
Mas não acho que esse benchmark seja suficiente para sustentar essa conclusão.
Tenho apenas uma execução medida do tsup nessa comparação.
Além disso, esta é uma biblioteca minúscula com um único entry point, em que a geração das declarações representa uma grande parte do build.
Os tempos reportados pelos próprios bundlers foram aproximadamente:
tsup
JavaScript: ~22 ms
dts: ~707 ms
tsdown
complete: ~717–755 ms
O tempo total foi melhor, mas com apenas uma amostra de tsup e a geração de declarações dominando uma biblioteca tão pequena, não considero isso evidência suficiente para afirmar que houve uma melhoria significativa de performance.
E não, eu não estou migrando porque economizei aproximadamente 300 ms.
🐱 A migração realmente foi pequena?
Para o is-kit, sim.
Não houve nenhuma alteração no código-fonte.
A migração basicamente ficou limitada a:
dependência
configuração
documentação das tarefas
assertions do smoke test do pacote
As principais opções tiveram uma correspondência quase um para um.
Mas acho importante explicar por que ela continuou pequena.
O is-kit tem:
- um único entry
- zero dependências em runtime
- nenhum plugin do bundler
- nenhum pipeline de CSS
- nenhum code splitting complexo
e, mais importante, já possuía uma forma de testar o pacote empacotado como um consumidor real.
A migração fica mais interessante se seu pacote depende de plugins customizados, entradas incomuns, processamento de CSS, source maps específicos, exports gerados, limites rígidos de tamanho ou ambientes de build com versões antigas do Node.
Também existe um requisito de ambiente de build que vale a pena verificar.
Para as versões testadas aqui:
tsdown 0.23.0
Rolldown 1.2.8
o requisito relevante de Node é:
^22.18.0 || ^24.11.0 || >=26.0.0
Meu CI usa:
Node 22.22.0
então estava tudo certo.
Mas se os contribuidores ainda fazem build da sua biblioteca com Node v20 ou com uma versão mais antiga do Node v22, isso é algo que você precisa resolver antes da migração.
Isso não significa necessariamente que os consumidores da biblioteca também precisem usar Node v22.
É um requisito da ferramenta de build.
Mesmo assim, os ambientes de CI e dos contribuidores também fazem parte do custo da migração. 😿
🎯 Então, valeu a pena migrar de tsup para tsdown?
Para o is-kit, acho que sim.
Mas não por causa de performance.
A migração preservou o contrato do pacote entre ESM, CJS, declarações, exports e os testes do pacote empacotado.
A mudança de configuração foi pequena e fácil de revisar.
Meu ambiente Node existente já atende ao novo requisito de build.
E agora o setup de build está alinhado ao ecossistema Rolldown, que continua evoluindo ativamente.
Mas também houve custos reais:
- o tamanho dos artefatos aumentou
- os requisitos de Node para o build aumentaram
- as extensões dos arquivos de saída precisaram ser configuradas explicitamente
Então eu não descreveria essa migração assim:
Todo mundo que usa tsup deveria migrar imediatamente porque tsdown é mais rápido!
Não foi isso que este experimento mostrou.
Para mim, uma conclusão melhor é:
Se seu projeto já atende aos requisitos de Node e você quer aproximar seu setup de build do ecossistema Rolldown, migrar uma pequena biblioteca de tsup para tsdown pode ser uma mudança bastante razoável.
Mas verifique o pacote que você publica.
Não apenas o código-fonte.
Não apenas a configuração.
E definitivamente não apenas a mensagem verde de build concluído. 😸
Porque o problema mais interessante desta migração aconteceu justamente quando o build dizia que estava tudo certo.
Se quiser ver o pacote usado neste artigo em um projeto real, o is-kit é open source.
É uma biblioteca TypeScript leve e sem dependências, focada em type guards para validação em runtime e narrowing seguro.
Se parecer útil para seu projeto, ficarei feliz se você experimentar — e uma ⭐ no GitHub é sempre bem-vinda.
https://github.com/nyaomaru/is-kit
Issues e PRs também são bem-vindos. 😸