Publicar tipos: los .d.ts son parte del contrato, no un extra
En 2026 una librería sin tipos está a medio publicar. El consumidor no recibe tu código TypeScript, recibe JavaScript compilado más un conjunto de archivos .d.ts que describen la forma de tu API: qué firmas tienen tus funciones, qué acepta cada opción, qué devuelve cada llamada. Generar esas declaraciones, apuntarlas con el campo types o la condición types de exports, y entender por qué los tipos son tan parte del contrato como el propio runtime: un cambio en un .d.ts puede romper la compilación de miles de consumidores sin tocar una sola línea de lógica. Cómo emitirlos, dónde colocarlos y por qué su compatibilidad se versiona igual que la del código.
Escribes tu librería en TypeScript, pero eso no es lo que publicas. El consumidor no recibe tu código con tipos: recibe JavaScript compilado, sin anotaciones, más un conjunto aparte de archivos .d.ts que describen la forma de tu API. Esos archivos de declaración son la única manera que tiene el editor de tu consumidor de saber qué acepta cada función, qué devuelve cada llamada y qué campos tiene cada opción. Sin ellos, tu librería se instala como una caja opaca: funciona, pero nadie sabe cómo llamarla sin abrir el código. En 2026, publicar sin tipos es publicar a medias, porque el .d.ts no es documentación opcional: es la mitad estática de tu contrato.
- Entender que el consumidor recibe JavaScript compilado más
.d.ts, no tu TypeScript. - Generar las declaraciones con el compilador y colocarlas junto a la salida.
- Apuntar los tipos con el campo
typesy la condicióntypesdeexports. - Asumir que un
.d.tses contrato: su compatibilidad se versiona como la del runtime.
Qué recibe de verdad el consumidor
Hay una separación que conviene fijar antes que nada: el runtime y los tipos viajan por caminos distintos. Cuando compilas tu librería, TypeScript produce dos cosas a partir del mismo fuente. Por un lado, el JavaScript que se ejecuta: tus clases y funciones sin una sola anotación de tipo, porque los tipos se borran al compilar. Por otro, los archivos .d.ts: declaraciones puras, sin implementación, que describen las firmas de todo lo que exportas. El consumidor instala ambos, pero los usa en momentos distintos —el .js cuando su código corre, el .d.ts cuando lo escribe y lo compila.
// index.ts (tu fuente)export function saludar(nombre: string): string { return `Hola, ${nombre}`;}De ahí salen dos artefactos. El JavaScript, que es lo que se ejecuta:
// dist/index.jsexport function saludar(nombre) { return `Hola, ${nombre}`;}Y la declaración, que es lo que el editor y el compilador del consumidor leen:
// dist/index.d.tsexport declare function saludar(nombre: string): string;La declaración no contiene lógica: solo la forma. Es el mapa que permite al editor del consumidor autocompletar saludar, avisarle si le pasa un número donde esperabas una cadena, y mostrar la firma al pasar el ratón. Sin ese .d.ts, el consumidor de TypeScript recibe un any implícito por todas partes o un error de que faltan las declaraciones: tu librería funciona en runtime, pero es ciega para las herramientas.
Generar y colocar las declaraciones
Las declaraciones no aparecen solas: hay que emitirlas. La vía canónica es pedirle al propio compilador de TypeScript que las genere activando la emisión de declaraciones, de modo que junto a cada .js salga su .d.ts correspondiente. En la práctica se separa la emisión del JavaScript —que suele hacer el bundler— de la emisión de los tipos —que hace tsc o una herramienta especializada—, y ambos escriben en la misma carpeta de salida.
{ "compilerOptions": { "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, "outDir": "dist" }}Con emitDeclarationOnly le dices a tsc que no emita JavaScript —de eso se encarga tu bundler— y que se limite a producir los .d.ts. El declarationMap añade sourcemaps de tipos, que permiten al editor del consumidor saltar de una firma a tu código fuente original. En 2026 las herramientas de empaquetado de librerías —tsdown, unbuild— integran este paso: generan el JavaScript y los tipos en una sola pasada, y a menudo agrupan las declaraciones en un único .d.ts por entry point para que la superficie publicada sea limpia.
Una vez emitidos, hay que declarar dónde están, y aquí es donde muchos paquetes fallan en silencio. El campo histórico es types (con su alias typings), que apunta al .d.ts del entry principal. Pero cuando usas exports, la forma correcta es la condición types dentro de cada subpath, y debe ir siempre la primera, antes que import o default, porque el resolvedor de TypeScript se queda con la primera condición que coincide.
{ "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } }}El orden de la condición types no es negociable
La condición types debe aparecer antes que import, require o default en cada rama de exports. El resolvedor de tipos de TypeScript recorre las condiciones en orden y se detiene en la primera que encaja; si default va antes, se lleva el .js y nunca llega al .d.ts, dejando al consumidor sin tipos aunque estén emitidos y publicados. Es el error más común y el más silencioso: el paquete funciona, los tipos existen en el dist, pero el editor del consumidor no los encuentra porque el orden del mapa los escondió.
Los tipos son contrato, no comentario
La idea que eleva esta lección por encima de la mecánica es que un .d.ts es tan vinculante como el código que ejecuta. Cambiar una firma —estrechar un parámetro, renombrar un campo de una opción, hacer obligatorio algo que era opcional— no toca una sola línea de lógica en runtime, pero rompe la compilación de todos los consumidores de TypeScript que dependían de la forma anterior. Su código seguiría ejecutándose igual; su build fallaría antes de llegar a ejecutarse. Por eso un cambio en los tipos es un cambio en la API, y se versiona con el mismo semver que gobierna el resto del paquete.
Esta simetría tiene una consecuencia incómoda y liberadora a la vez: los tipos amplían tu superficie pública más allá del runtime. Un tipo que exportas —una interfaz, un genérico, un alias— pasa a ser algo de lo que otros dependen, y no puedes cambiarlo sin coste. Lo que crees un detalle interno, si sale en un .d.ts, es contrato. La disciplina de una librería madura incluye por eso auditar qué tipos son públicos con el mismo cuidado con que audita qué funciones exporta: cada export de tipo es una promesa que alguien compilará contra ti.
La verificación no es opcional: prueba tus tipos como pruebas tu código
Igual que ejecutas tests del runtime, en 2026 se verifica que los tipos publicados son correctos y resolubles. Herramientas como @arethetypeswrong/cli comprueban que el consumidor, bajo cada condición de exports, encuentra un .d.ts que encaja con el .js que se le sirve —el caso frecuente de tipos que resuelven para ESM pero no para CJS, o al revés—. Y tsc sobre un proyecto de prueba que importa tu paquete es la manera más honesta de confirmar que tu contrato estático compila. Publicar tipos sin verificarlos es como publicar código sin ejecutarlo.
El doble mundo: tipos para ESM y para CJS
Si publicas un paquete dual, los tipos heredan la misma dualidad que el runtime, y ahí anida un error sutil. Un único index.d.ts describe un sistema de módulos, y TypeScript, según cómo el consumidor te importe, espera un .d.ts que declare export de ESM o uno que declare export = de CommonJS. Servir el archivo equivocado bajo una condition produce el fallo desconcertante de tipos que resuelven para un consumidor y fallan para otro, aunque el runtime funcione en ambos.
{ "exports": { ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } } }}Fíjate en el patrón: cada condition de runtime —import y require— anida su propia condition types, apuntando a un .d.ts para el mundo ESM y a un .d.cts para el de CommonJS. No basta con un tipo global en la raíz; el mapa de tipos debe seguir al mapa de runtime rama por rama, porque la forma de las declaraciones cambia con el sistema de módulos que describen.
Este es exactamente el terreno que verifica @arethetypeswrong/cli: comprueba que, bajo cada forma de importar, el consumidor encuentra un .d.ts coherente con el .js que se le sirve. El error clásico que caza —tipos que resuelven para ESM pero no para CJS, o el temido masquerading as CJS— es invisible en tu máquina y evidente para el consumidor que te importa del otro lado. Por eso publicar tipos en un paquete dual sin pasar esa verificación es firmar un contrato que no has leído.
.d.ts
Declaraciones sin lógica: solo la forma de tu API. Es lo que el editor del consumidor lee para autocompletar y chequear.
Emitir tipos
tsc con emitDeclarationOnly o una herramienta de librerías genera los .d.ts junto a la salida del bundler.
Condición types
Dentro de exports, la primera de cada rama. Si va después de default, el consumidor se queda sin tipos.
Tipos = contrato
Cambiar una firma rompe la compilación de otros aunque el runtime no cambie. Se versiona con semver.
Un paquete tiene dos contratos: uno que se ejecuta y otro que se compila
La revelación que cierra esta lección es que una librería moderna no publica una interfaz, sino dos, y ambas son igual de vinculantes. La primera es la de runtime: el JavaScript que corre, las funciones que se llaman, los valores que devuelven. La segunda es la de tipos: el conjunto de .d.ts que describe la forma de todo eso y que el compilador del consumidor consulta antes de que una sola línea se ejecute. Durante años se trató la segunda como un adorno —“ya añadiré los tipos si me da tiempo”—, y esa mentalidad es la que hay que desterrar, porque en 2026 el .d.ts es tan parte del paquete como el .js. La prueba está en la asimetría de cómo se rompen: puedes cambiar la implementación de una función sin tocar su firma y ningún consumidor se entera —refactor libre—; pero cambia la firma sin tocar la implementación y romperás la build de miles de personas cuyo runtime habría funcionado perfectamente. El fallo migra del runtime al compilador, pero sigue siendo un fallo que tú causaste y que el semver debía haber anunciado. Entender esto reordena tu forma de publicar. Los tipos dejan de ser un anexo generado al final y pasan a ser materia de diseño: qué interfaces exportas es una decisión de superficie pública tan seria como qué funciones exportas, porque cada tipo público es una promesa que otros compilarán contra ti y que no podrás romper sin un cambio mayor. Cablearlos bien —emitirlos, colocarlos, apuntarlos con la condición types en el orden correcto, verificarlos con las herramientas del ecosistema— no es fontanería opcional: es cumplir la mitad del contrato que tu paquete firma con el mundo. Una librería sin tipos, o con tipos que el consumidor no puede resolver, no está terminada; está publicada a medias, ofreciendo un runtime sin el mapa que lo hace usable. Y en un ecosistema donde la mayoría del código consumidor es TypeScript, ese mapa no es un lujo: es la diferencia entre una dependencia que la gente adopta con confianza y una caja opaca que se instala a ciegas.
Cierra el contrato estático
- Compila una librería mínima con
emitDeclarationOnlyy confirma que junto aindex.jsaparece unindex.d.tscon las firmas correctas. - Cablea
exportscon la condicióntypesla primera de la rama, e impórtala desde un proyecto TypeScript para verificar que el autocompletado funciona. - Coloca a propósito
typesdespués dedefaulty observa cómo el consumidor pierde los tipos aunque el.d.tsexista. - Pasa
@arethetypeswrong/clisobre tu paquete y corrige cualquier caso en que los tipos no resuelvan bajo alguna condición. - Cambia una firma pública —haz obligatorio un parámetro opcional— y razona por qué eso es un cambio mayor aunque el runtime no varíe.