JavaScript

Migrar tslane a un paquete dual CommonJS y ESM con tsup y Vitest

tslane es la plantilla que uso como punto de partida para mis librerías TypeScript, nacida de la serie de 2020 y actualizada varias veces desde entonces. La más reciente, sobre consistencia de nombres con ESLint. En esta entrada aplico el razonamiento de la primera entrada de esta serie a este proyecto, pero con una diferencia clave respecto a la anterior: tslane no es una aplicación, es una plantilla para librerías que otros van a consumir. Eso descarta pasar a "type": "module" sin más, porque convertiría en ESM-only a cualquier librería nacida de esta plantilla, rompiendo a quien la use desde un proyecto CommonJS.

Como es una plantilla sin código propio que mantener ni versiones publicadas a las que dar soporte, este es precisamente el mejor momento para decidir esto bien: cualquier librería nueva partirá ya con la decisión correcta tomada, sin arrastrar deuda técnica proyecto a proyecto.

Punto de partida

// tsconfig.json (antes)
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "declaration": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
// package.json (antes, extracto)
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
  "build": "npx tsc",
  "bundle": "npx webpack --config-name=production",
  "coverage": "npx jest --collectCoverage",
  "pack": "npx del-cli ./dist && npm run build && node prepack.js && cd dist && npm pack && cd ..",
  "test": "npx jest"
},
"devDependencies": {
  "jest": "^30.0.0",
  "ts-jest": "^29.4.0",
  "ts-loader": "^9.5.2",
  "webpack": "^5.99.9",
  "webpack-cli": "^6.0.1",
  "npm-dts-webpack-plugin": "^1.3.13"
  // ...
}

El build actual combina dos pipelines: tsc para generar dist/ con las declaraciones y Webpack para un bundle adicional. Por otro lado, los tests usan Jest + ts-jest. Vamos a sustituir ambos por herramientas pensadas específicamente para este caso.

Por qué paquete dual y no ESM-only

Como se explicaba en la primera entrada de esta serie, declarar "type": "module" a secas convierte cualquier require('tu-libreria') en un error para quien la consuma. Como no sabemos de antemano si las librerías nacidas de esta plantilla las usarán solo en proyectos propios modernos o también terceros en CommonJS, la opción que no cierra puertas es publicar ambas salidas y dejar que Node, o el bundler de quien te consuma, elija la correcta automáticamente a través del campo "exports" del package.json.

Sustituir tsc + Webpack por tsup

npm uninstall webpack webpack-cli ts-loader npm-dts-webpack-plugin
npm install --save-dev tsup
// tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  // Paquete dual: genera index.js (ESM) + index.cjs (CommonJS)
  format: ['esm', 'cjs'],
  // Genera index.d.ts (ESM) + index.d.cts (CommonJS), en línea con
  // el campo "exports" del package.json
  dts: true,
  sourcemap: true,
  clean: true,
  target: 'es2020',
});

Con esto, un único npm run build genera las cuatro salidas (index.js, index.cjs, index.d.ts, index.d.cts) que antes exigían dos herramientas distintas.

Actualizar package.json

{
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "test": "vitest run",
    "coverage": "vitest run --coverage",
    "typecheck": "tsc --noEmit",
    "doc": "npx typedoc --out docs src",
    "pack": "npx del-cli ./dist && npm run build && node prepack.js && cd dist && npm pack && cd .."
  }
}

El campo "exports" es el que resuelve de verdad el paquete dual: quien haga import recibe la build ESM con sus tipos, quien haga require recibe la build CJS con los suyos. Los campos main/module/types se mantienen como alternativa para herramientas más antiguas que todavía no leen "exports".

Ajustar tsconfig.json

Como ahora tsup (esbuild) es quien transpila de verdad, el tsconfig.json pasa a usarse sobre todo para el chequeo de tipos en el editor y en CI, no para generar el build:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "declaration": true,
    "sourceMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": ["src"],
  "exclude": ["dist", "node_modules", "tests"]
}

De paso subimos el target de es5 a es2020, es5 solo tiene sentido si necesitas dar soporte a navegadores muy antiguos, y no es el caso de esta plantilla.

Migrar los tests de Jest a Vitest

npm uninstall jest ts-jest @types/jest
npm install --save-dev vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['tests/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**/*.ts'],
    },
  },
});

La API de Vitest (describe, it, expect…) es prácticamente idéntica a la de Jest, así que los tests existentes en tests/ deberían necesitar pocos o ningún cambio más allá de sustituir jest.fn()/jest.mock() por vi.fn()/vi.mock() si los usas. Al estar construido sobre esbuild, Vitest soporta ESM y TypeScript de forma nativa, sin la fricción que da configurar Jest sobre un proyecto ESM.

Actualizar prepack.js

El script prepack.js genera el package.json que se publica dentro de dist/. Con el build anterior (un único index.js en CommonJS) bastaba con apuntar main/types/module a ese archivo. Con el paquete dual, necesita generar también el "exports" correcto:

// prepack.js
import { readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));

const distPkg = {
  ...pkg,
  main: './index.cjs',
  module: './index.js',
  types: './index.d.ts',
  exports: {
    '.': {
      import: { types: './index.d.ts', default: './index.js' },
      require: { types: './index.d.cts', default: './index.cjs' },
    },
    './package.json': './package.json',
  },
};

delete distPkg.files;
delete distPkg.devDependencies;
delete distPkg.scripts;

writeFileSync(
  join(__dirname, 'dist', 'package.json'),
  JSON.stringify(distPkg, null, 2) + '\n',
);
copyFileSync(join(__dirname, 'README.md'), join(__dirname, 'dist', 'README.md'));

Si no se actualiza este script, cualquier librería creada desde la plantilla publicaría con paquete dual en el build, pero con metadatos de un único build en el package.json publicado —perdiendo la mitad del beneficio del cambio.

Verificar la publicación

Antes de dar la migración por buena, conviene comprobar que el paquete dual funciona de verdad desde los dos lados:

npm run pack

Y luego, en un proyecto de prueba aparte, instalar el .tgz generado y probar tanto:

// consumidor ESM
import { array } from 'tslane';

como:

// consumidor CommonJS
const { array } = require('tslane');

Si ambos funcionan sin errores, el paquete dual está correctamente configurado.

Conclusiones

A diferencia de expresslanets, donde el salto a ESM fue directo por no tener consumidores externos, en tslane la solución pasa por dejar montado un paquete dual CommonJS/ESM desde el principio, usando tsup en lugar de la combinación tsc + Webpack, y Vitest en lugar de Jest + ts-jest. El resultado es una plantilla que da a cada futura librería compatibilidad amplia sin que tengas que volver a pensarlo en cada proyecto nuevo.

Nota: La imagen de este artículo fue generada utilizando un modelo de inteligencia artificial.

¿Te ha parecido de utilidad el contenido?

Daniel Rodríguez

Share
Published by
Daniel Rodríguez
Tags: TypeScript

Recent Posts

Cómo construir tu primer scorecard paso a paso

En los artículos anteriores de esta serie vimos la teoría detrás del credit scoring: qué…

2 días ago

Actualizar expresslanets a ES Modules con tsx y Vitest

expresslanets es la plantilla de API REST con Express y TypeScript que empezó como una…

1 semana ago

Pareto/NBD: cuando el abandono silencioso cambia el CLV

En el artículo del Dashboard vimos que Pareto/NBD aparecía junto a BG/NBD con un ajuste…

1 semana ago

Por qué migrar tus proyectos TypeScript a ES Modules en 2026

Durante años, CommonJS (require/module.exports) ha sido la forma por defecto de organizar módulos en Node.js,…

2 semanas ago

Método del codo: interpretación correcta y limitaciones

Cuando usas k-means tienes que decidir algo incómodo de entrada: cuántos grupos, , vas a…

2 semanas ago

Errores comunes al interpretar resultados estadísticos

La estadística no suele fallar en los cálculos: falla en la interpretación. El test está…

3 semanas ago

This website uses cookies.