wandres.dev
HERRAMIENTAS DE BUILD · Vite lib, tsup, unbuild

Generar tipos .d.ts: emitir, empaquetar y el futuro en Rust

Las declaraciones de tipos son un problema aparte del empaquetado del código, y por una razón de fondo: los transpiladores rápidos —esbuild, SWC, Oxc— no comprueban tipos, así que no pueden inferir un `.d.ts` a partir de la lógica. `tsc --emitDeclarationOnly` emite declaraciones fieles pero espejando el árbol de archivos; `rollup-plugin-dts` y api-extractor las empaquetan en un único archivo con tree shaking de tipos; e `isolatedDeclarations` abre la puerta a generarlas rápido y en paralelo, incluso desde herramientas en Rust.

⏱ 17 min

Hay una asimetría en el corazón del empaquetado de librerías de TypeScript que explica casi todos sus dolores: el código y sus tipos se generan por caminos distintos, con herramientas distintas y a velocidades distintas. Un transpilador moderno convierte tu TypeScript en JavaScript borrando los tipos en microsegundos, pero precisamente porque los borra no puede escribir el .d.ts que tu consumidor necesita para tener autocompletado. Emitir declaraciones exige entender los tipos, no solo tacharlos, y entenderlos es otro trabajo distinto. Este es el capítulo donde separas de una vez esas dos mitades y aprendes a producir la de los tipos con criterio en vez de por copia.

🎯 Al terminar esta lección sabrás
  • Entender por qué los transpiladores rápidos no pueden emitir declaraciones por sí solos.
  • Usar tsc --emitDeclarationOnly para emitir .d.ts fieles al árbol de archivos.
  • Empaquetar las declaraciones en un único archivo con rollup-plugin-dts o api-extractor.
  • Situar isolatedDeclarations como el habilitador de la generación rápida en Rust.

Emitir frente a empaquetar declaraciones

Conviene separar dos operaciones que se confunden a menudo. Emitir declaraciones es producir, a partir de tu código, los archivos .d.ts que describen su superficie de tipos. Empaquetarlas es tomar esos muchos archivos —normalmente uno por cada módulo fuente— y fundirlos en una sola declaración que acompañe al bundle. Son problemas independientes: puedes emitir sin empaquetar, y necesitas emitir antes de poder empaquetar.

La distinción no es pedante: determina cuántos archivos publicas, si los tipos siguen al bundle o al árbol de módulos, y qué herramienta necesitas para cada mitad del trabajo.

La herramienta canónica para emitir es el propio compilador de TypeScript. Con la bandera adecuada, tsc no genera JavaScript, solo declaraciones:

# Emite solo los d.ts, sin JavaScript, espejando src en dist
tsc --emitDeclarationOnly --declaration --outDir dist

El resultado es fiel pero tiene una forma concreta: un .d.ts por cada archivo de tu src, replicando la estructura de carpetas. Para una librería que publica su árbol de módulos tal cual, eso puede bastar. Pero si empaquetaste el código en un solo index.js, tener los tipos dispersos en veinte archivos rompe la correspondencia: el consumidor importa un bundle pero sus tipos apuntan a una estructura que ya no existe.

tsc y el empaquetado con rollup-plugin-dts

Ahí entra el empaquetado de declaraciones. rollup-plugin-dts es un plugin que trata los .d.ts como si fueran código: construye un grafo con ellos, elimina los tipos que no se exportan —un tree shaking de tipos— y emite un único archivo de declaraciones que se corresponde con tu bundle. El flujo habitual es de dos fases: primero tsc emite las declaraciones por archivo, luego el plugin las funde en una.

// rollup.config.dts.ts
import dts from "rollup-plugin-dts";

export default {
  input: "dist/index.d.ts",
  output: { file: "dist/index.d.ts", format: "es" },
  plugins: [dts()],
};

La alternativa de referencia en proyectos grandes es api-extractor, de Microsoft. Hace lo mismo —empaquetar declaraciones— pero añade dos capacidades que importan a escala: genera un informe de la API pública, de modo que cualquier cambio en la superficie de tipos queda registrado y revisable, y distingue niveles de visibilidad. Para una librería con muchos consumidores, ese control sobre qué entra y qué no en la API pública vale el peso extra de la herramienta.

  • tsc --emitDeclarationOnly: emite declaraciones fieles, una por archivo, sin JavaScript.
  • rollup-plugin-dts: empaqueta esas declaraciones en un único .d.ts con tree shaking de tipos.
  • api-extractor: empaqueta y además produce un informe de la API pública y controla su visibilidad.
  • El orden importa: primero se emite, luego se empaqueta; nunca al revés.

Cuando publicas un paquete dual, cada formato quiere su propia declaración para que el modo de módulo del tipo coincida con el del código, y el exports las enruta por separado:

{
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  }
}

Fíjate en que la condición types va primero dentro de cada rama, y en que el import recibe un .d.mts mientras el require recibe un .d.cts. Esa simetría entre código y tipos es la que evita que un consumidor en ESM lea por error las declaraciones pensadas para CommonJS.

⚠️
Los mapas de tipos y el campo types

Empaquetar las declaraciones no termina el trabajo: el package.json tiene que apuntar a ellas. El campo types —o la condición types dentro de exports— le dice a TypeScript dónde está el .d.ts de cada entrada, y debe ir antes que import y require en el orden de condiciones, porque TypeScript lee la primera que aplica. Si además publicas un paquete dual, cada formato puede querer su propia declaración —.d.mts y .d.cts— para que el modo de módulo del tipo coincida con el del código. Un tipo mal enrutado no rompe el runtime, pero deja al consumidor sin autocompletado, que para una librería es casi peor.

isolatedDeclarations y el futuro en Rust

La razón última de que emitir tipos sea lento es que tsc necesita, en el caso general, inferir a través de todo el grafo: para saber el tipo que exporta una función puede tener que seguir lo que devuelve otra en otro archivo, y así en cadena. Esa inferencia global es potente pero imposible de paralelizar bien, y ata la generación de tipos a la velocidad del comprobador entero.

isolatedDeclarations, introducido en TypeScript 5.5, cambia el trato. Al activarlo, el compilador te obliga a anotar de forma explícita los tipos en las fronteras públicas —los tipos de retorno de las funciones exportadas, por ejemplo—, de modo que cada archivo pueda declarar su superficie sin mirar a los demás. A cambio de esa disciplina, la generación de declaraciones deja de necesitar inferencia entre archivos: se vuelve local, paralelizable y, sobre todo, factible para una herramienta que no sea tsc.

Esa es la puerta que cruzan las herramientas en Rust de 2026. Oxc puede emitir declaraciones cuando el proyecto cumple isolatedDeclarations, porque ya no hace falta reimplementar el comprobador entero de TypeScript, solo leer las anotaciones que el autor puso en las fronteras. Así, tsdown y el rolldown-plugin-dts generan los .d.ts a velocidad de Rust en vez de delegar en una pasada lenta, y la asimetría con la que abrimos —código rápido, tipos lentos— por fin se estrecha.

flowchart TD
SRC[codigo TypeScript] --> EMIT[emitir declaraciones]
EMIT --> TSC[tsc emitDeclarationOnly una por archivo]
EMIT --> ISO[isolatedDeclarations rapido y en paralelo]
TSC --> BUND[empaquetar declaraciones]
ISO --> BUND
BUND --> RPD[rollup-plugin-dts o api-extractor]
RPD --> ONE[un unico archivo de tipos]

Cablear los dos artefactos en la práctica

Ver los dos flujos por separado está bien; en un proyecto real tienes que orquestarlos, y el lugar donde se orquestan es el package.json. El patrón clásico de dos fases se escribe como dos scripts encadenados: primero se emiten las declaraciones por archivo con tsc, luego se empaquetan en una con el plugin de dts, y el build del código corre en paralelo o antes.

{
  "scripts": {
    "build:js": "tsup src/index.ts --format esm,cjs",
    "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
    "build": "npm run build:js && npm run build:types"
  }
}

El detalle que decide si el consumidor tendrá tipos no es ninguno de esos comandos, sino el enrutado. El package.json debe apuntar a las declaraciones con el campo types o con la condición types dentro de exports, y esa condición tiene que ir antes que import y require, porque TypeScript lee la primera que aplica y se detiene. Un build que emite .d.ts perfectos pero no los enruta deja al consumidor exactamente igual que si no los hubieras generado.

Interioriza que el código y los tipos son dos caminos que solo se reencuentran en el package.json: uno produce el JavaScript, otro las declaraciones, y el enrutado es el nudo que los presenta al consumidor como una sola cosa coherente.

  • Dos fases encadenadas: emitir con tsc, luego empaquetar con el plugin de dts.
  • Código en paralelo: el bundle del JavaScript no depende de los tipos y puede correr a la vez.
  • types primero: en exports, la condición de tipos va antes que import y require.
  • Emitir sin enrutar no sirve: un .d.ts que el package.json no anuncia es invisible.
  • Un comando en 2026: las herramientas modernas integran emitir y empaquetar en una sola pasada.
💡
Deja que la herramienta unifique las dos fases

El patrón de dos scripts es didáctico porque muestra las piezas, pero en 2026 casi nunca lo escribirás a mano: tsup, tsdown y unbuild ya integran la generación de tipos en el mismo comando de build, con dts: true o declaration: true. Escribir los scripts separados sirve para entender qué ocurre por debajo —dos artefactos, dos caminos, un enrutado— pero la herramienta moderna te ahorra la orquestación. Entiende el mecanismo para poder depurarlo; deja la ejecución a quien ya sabe encadenar las fases.

✂️

El transpilador no basta

esbuild, SWC y Oxc borran los tipos, no los entienden. Por eso no pueden emitir un .d.ts a partir de tu lógica.

📐

tsc emitDeclarationOnly

El compilador emite declaraciones fieles, una por archivo, espejando src. Correcto, pero disperso frente a un bundle.

📦

Empaquetar tipos

rollup-plugin-dts y api-extractor funden los .d.ts en uno solo, con tree shaking de tipos e informe de API.

🦀

isolatedDeclarations

Anotar las fronteras hace la emisión local y paralela, y habilita generar tipos a velocidad de Rust con Oxc.

El tipo es un segundo artefacto, y tratarlo como una ocurrencia tardía se paga

La trampa mental que arruina más publicaciones de librerías es creer que el .d.ts es un subproducto automático del build, algo que sale gratis por el lado. No lo es. Tu librería distribuye dos artefactos con vidas paralelas: el JavaScript, que el runtime ejecuta, y las declaraciones, que el compilador del consumidor lee. Se generan por caminos distintos, pueden desincronizarse, tienen su propio empaquetado, su propio enrutado en exports y su propio coste de tiempo, y sin embargo el consumidor los vive como una sola cosa: o tu paquete tiene autocompletado impecable o no lo tiene. Interiorizar que el tipo es un segundo artefacto de primera clase reordena todas tus decisiones. Entiendes por qué el dts domina el tiempo de build en tsup —es un compilador entero corriendo en paralelo al transpilador—. Entiendes por qué isolatedDeclarations pide anotaciones —está comprando localidad y paralelismo a cambio de disciplina, exactamente el trato que permite a Oxc emitir tipos sin ser TypeScript—. Entiendes por qué un paquete dual necesita .d.mts y .d.cts —porque el tipo también tiene modo de módulo—. Y entiendes por qué el campo types debe ir primero en exports —porque un tipo mal enrutado es invisible hasta que un usuario se queja de que perdió el autocompletado—. La diferencia entre una librería que se siente profesional y una que frustra rara vez está en el código: está en si su autor trató los tipos como el artefacto que son o como una casilla que marcar. Diseña la salida de tipos con la misma intención con que diseñas la de código, porque tu consumidor la usa tanto como la otra, solo que sin darse cuenta.

⚔️ Separa el código de sus tipos y vuelve a unirlos
  1. Ejecuta tsc --emitDeclarationOnly --declaration sobre una librería y observa cómo los .d.ts espejan la estructura de tu src.
  2. Empaqueta esas declaraciones con rollup-plugin-dts en un único archivo y compáralo con los dispersos que emitió tsc.
  3. Enruta el campo types en el exports del package.json y verifica desde un consumidor que el autocompletado aparece.
  4. Activa isolatedDeclarations en tu tsconfig y corrige los errores que exijan anotar los tipos de retorno en las fronteras públicas.
  5. Empaqueta con una herramienta que aproveche isolatedDeclarations y razona por qué la generación de tipos deja de ser el cuello de botella.