Lección 8 de 8. La última: cómo se reparte un programa de JavaScript en varios archivos (módulos), cómo se instalan paquetes con npm y cómo se comprueba con pruebas automáticas que el código hace lo que debe. Con un proyecto pequeño de verdad, montado y ejecutado archivo por archivo.
Por qué módulos
Hasta ahora, cada ejemplo cabía en un archivo. Un programa de verdad no: se reparte en archivos con una responsabilidad cada uno. En JavaScript, cada archivo es un módulo: lo que declara es privado salvo que lo exporte, y para usar algo de otro archivo hay que importarlo. Vamos a montar un proyecto pequeño, una tienda, con esta estructura:
tienda/
├── package.json
├── src/
│ ├── precios.js
│ ├── carrito.js
│ └── main.js
└── test/
└── precios.test.jspackage.json
Todo proyecto de Node empieza con un package.json, la ficha del proyecto: nombre, versión, dependencias y órdenes. npm init -y crea uno con los valores por omisión:
npm init -y
salidaWrote to /home/node/nuevo/package.json:
{
"name": "nuevo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}Fíjate en la última línea: las versiones recientes de npm escriben "type": "commonjs", el sistema de módulos antiguo. Lo cambiamos, y dejamos el archivo así:
package.json
{
"name": "tienda",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/main.js",
"test": "node --test"
}
}"type": "module" le dice a Node que los archivos .js de este proyecto usan import y export. Sin esa línea, Node los trata como CommonJS, el sistema antiguo (más abajo). Y scripts define órdenes con nombre: npm start y npm test ejecutan lo que pone en cada una.
export e import
src/precios.js
export const IVA = 0.16;
export function conIva(precio) {
return Math.round(precio * (1 + IVA) * 100) / 100;
}
export default function formatear(precio) {
return `${precio.toFixed(2)} USD`;
}Hay dos clases de exportación. Las con nombre (export const, export function) pueden ser muchas por archivo, y se importan con su nombre exacto entre llaves. La por defecto (export default) es una sola, y quien la importa le pone el nombre que quiera.
src/carrito.js
import { conIva } from "./precios.js";
export function total(lineas) {
return lineas.reduce((suma, l) => suma + conIva(l.precio) * l.cantidad, 0);
}src/main.js
import formatear, { conIva, IVA } from "./precios.js";
import * as carrito from "./carrito.js";
const lineas = [
{ nombre: "café", precio: 7.5, cantidad: 2 },
{ nombre: "miel", precio: 4.9, cantidad: 1 },
];
console.log(`IVA: ${IVA * 100} %`);
console.log("café con IVA:", formatear(conIva(7.5)));
console.log("total:", formatear(carrito.total(lineas)));npm start
salida> tienda@1.0.0 start > node src/main.js IVA: 16 % café con IVA: 8.70 USD total: 23.08 USD
precios.js no sabe quién lo usa: solo decide qué deja ver. Esquema: decodigo.com.| Escribes | Importa |
|---|---|
import { conIva } from "./precios.js" | una exportación con nombre |
import { conIva as iva } from "./precios.js" | la misma, con otro nombre |
import formatear from "./precios.js" | la exportación por defecto, con el nombre que elijas |
import * as precios from "./precios.js" | todo, como propiedades de un objeto: precios.conIva |
import fs from "node:fs" | un módulo de Node (el prefijo node: lo deja claro) |
import { format } from "date-fns" | un paquete instalado con npm, sin ruta |
Los tres errores de los módulos
En Node, la ruta de un archivo propio lleva la extensión completa. Sin ella no lo encuentra:
src/sin-extension.js
import { conIva } from "./precios";
console.log(conIva(10));salidaError [ERR_MODULE_NOT_FOUND]: Cannot find module '/home/node/tienda/src/precios' imported from /home/node/tienda/src/sin-extension.js
Un nombre que el otro archivo no exporta (aquí, por una mayúscula) falla antes de ejecutar nada, al enlazar los módulos:
src/mal-nombre.js
import { conIVA } from "./precios.js";
console.log(conIVA(10));salidaSyntaxError: The requested module './precios.js' does not provide an export named 'conIVA'
Y require, que verás en muchísimos tutoriales, no existe en un módulo ES:
src/con-require.js
const fs = require("node:fs");
console.log(fs.existsSync("package.json"));salidaReferenceError: require is not defined in ES module scope, you can use import instead
CommonJS: el sistema anterior
Antes de que import llegara al lenguaje en 2015, Node inventó su propio sistema, CommonJS, con require y module.exports. Sigue en millones de proyectos y paquetes, así que hay que saber leerlo. Un archivo .cjs es siempre CommonJS, diga lo que diga el package.json:
src/viejo-util.cjs
function conIva(precio) {
return Math.round(precio * 1.16 * 100) / 100;
}
module.exports = { conIva };src/viejo.cjs
const { conIva } = require("./viejo-util.cjs");
const path = require("node:path");
console.log(conIva(10), path.basename(__filename));node src/viejo.cjs
salida11.6 viejo.cjs
| Módulos ES | CommonJS | |
|---|---|---|
| Importar | import x from "./a.js" | const x = require("./a") |
| Exportar | export, export default | module.exports = ... |
| Archivos | .mjs, o .js con "type": "module" | .cjs, o .js sin "type" |
await fuera de funciones | sí | no |
| Funciona en el navegador | sí, con <script type="module"> | no |
Para código nuevo, módulos ES: son el estándar del lenguaje y los mismos en Node y en el navegador. Cómo se cargan en una página está en el ejemplo la etiqueta script.
npm: instalar paquetes
npm es el gestor de paquetes que viene con Node, y su registro es el mayor del mundo. Para manejar fechas con algo más cómodo que Date, por ejemplo, la biblioteca date-fns:
npm install date-fns
salidaadded 1 package, and audited 2 packages in 2s found 0 vulnerabilities
src/fechas.js
import { format, addDays, differenceInDays } from "date-fns";
import { es } from "date-fns/locale";
const pedido = new Date(2026, 9, 19);
const entrega = addDays(pedido, 3);
console.log(format(entrega, "EEEE d 'de' MMMM", { locale: es }));
console.log(differenceInDays(entrega, pedido), "días");node src/fechas.js
salidajueves 22 de octubre 3 días
La instalación hace tres cosas: descarga el paquete en la carpeta node_modules, lo apunta en las dependencies del package.json con su versión (aquí, "date-fns": "^4.4.0") y fija la versión exacta de todo en package-lock.json.
package.json y package-lock.json, sí; node_modules, nunca: se regenera entera con npm install (o npm ci, que instala exactamente lo que dice el archivo de bloqueo). Y antes de instalar un paquete, mira cuántas descargas tiene y quién lo mantiene: cada dependencia es código de otra persona que se ejecuta con tus permisos.Pruebas con node –test
Node trae su propio sistema de pruebas desde la versión 20, sin instalar nada. Un archivo de pruebas importa test y assert, y comprueba que el código hace lo que debe:
test/precios.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { conIva } from "../src/precios.js";
import { total } from "../src/carrito.js";
test("conIva suma el 16 %", () => {
assert.equal(conIva(100), 116);
});
test("conIva redondea a dos decimales", () => {
assert.equal(conIva(4.9), 5.68);
});
test("el total de un carrito vacío es 0", () => {
assert.equal(total([]), 0);
});
test("total multiplica por la cantidad", () => {
assert.equal(total([{ precio: 10, cantidad: 3 }]), 34.8);
});npm test
salida> tienda@1.0.0 test > node --test ✔ conIva suma el 16 % (0.655061ms) ✔ conIva redondea a dos decimales (0.107782ms) ✔ el total de un carrito vacío es 0 (0.141636ms) ✔ total multiplica por la cantidad (0.1326ms) ℹ tests 4 ℹ suites 0 ℹ pass 4 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 59.662574
npm test ejecuta el script del package.json; node --test encuentra los archivos que acaban en .test.js y ejecuta cada prueba. Esquema: decodigo.com.node --test sin más argumentos busca solo los archivos de prueba: los que acaban en .test.js o están en una carpeta test, entre otros patrones. assert.equal compara con === (por eso se importa node:assert/strict) y assert.deepEqual compara objetos y arrays por su contenido, que con === no se puede, como se vio en la lección 5.
Cuando una prueba falla, la salida dice qué se esperaba y qué llegó. Para verlo sin estropear las pruebas buenas, la ponemos aparte, en fallos/redondeo.js: un nombre que no encaja en ningún patrón de prueba, porque con redondeo.test.js el npm test de arriba también la habría recogido (lo comprobamos: es lo que pasó en el primer intento).
fallos/redondeo.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { conIva } from "../src/precios.js";
test("conIva de 7.5", () => {
assert.equal(conIva(7.5), 8.69);
});node --test fallos/redondeo.js
salida✖ conIva de 7.5 (1.053089ms) ℹ tests 1 ℹ suites 0 ℹ pass 0 ℹ fail 1 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 47.551718 ✖ failing tests: test at fallos/redondeo.js:5:1 ✖ conIva de 7.5 (1.053089ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 8.7 !== 8.69
7.5 por 1.16 da 8.7, no 8.69: la prueba estaba mal escrita, y es justo lo que una prueba que falla te obliga a mirar. El proceso termina con código de salida 1, que es lo que usan los servicios de integración continua para marcar el cambio como roto. Dos órdenes más que ahorran tiempo: node --test --watch repite las pruebas cada vez que guardas un archivo, y node --watch src/main.js hace lo mismo con el programa.
Y TypeScript
TypeScript es JavaScript con tipos: se declara qué es cada variable y cada parámetro, y un comprobador avisa de los errores antes de ejecutar. Se ha convertido en lo normal en proyectos grandes. Node 24 puede ejecutar un archivo .ts directamente:
src/factura.ts
interface Linea {
nombre: string;
precio: number;
cantidad: number;
}
function subtotal(l: Linea): number {
return l.precio * l.cantidad;
}
const lineas: Linea[] = [
{ nombre: "café", precio: 7.5, cantidad: 2 },
{ nombre: "miel", precio: 4.9, cantidad: 1 },
];
console.log(lineas.map(subtotal));node src/factura.ts
salida[ 15, 4.9 ]
Pero ojo con lo que hace: borra los tipos y ejecuta lo que queda. No los comprueba. Este archivo tiene un error de tipos evidente y Node lo ejecuta igual:
src/tipo-mal.ts
const precio: number = "siete"; console.log(precio, typeof precio);
node src/tipo-mal.ts
salidasiete string
Para que alguien detecte ese error hace falta el compilador de TypeScript, tsc, o un editor que lo use por debajo. Y algunas construcciones de TypeScript que no son solo tipos, porque generan código, como los enum, no se pueden «borrar» sin más:
src/estado.ts
enum Estado {
Pendiente,
Enviado,
}
console.log(Estado.Enviado);node src/estado.ts
salidaSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
Lo que has aprendido en el curso
Con esta lección termina el curso. En ocho lecciones has pasado por los tipos y sus conversiones, el control de flujo, las funciones y los closures, los arrays y los objetos, las clases y this, los errores y la asincronía, y ahora los módulos, npm y las pruebas. Es todo lo que necesitas para leer casi cualquier código de JavaScript moderno.
Para comprobarlo, el test de JavaScript: 24 preguntas sobre el curso, que se corrigen en tu navegador. Para seguir practicando, los ejemplos de JavaScript, con tareas concretas de Node y del navegador. Y para reírte un rato (o llorar), las rarezas de JavaScript.
Todo el código de esta lección se ejecutó con Node.js 24 en un contenedor limpio antes de publicarla; las salidas y los errores están copiados de esa ejecución.
