Al cerrar la lección anterior el modelo de Nómada Tareas estaba completo y protegido: Tarea con su estado privado, Tablero con su API pública mínima, ErrorDeValidacion con su jerarquía. El problema es que todo eso vive en un único js/app.js que ya pasa de las trescientas líneas, mezclado con las constantes, el backlog de ejemplo y el código que lo ejecuta. Partirlo en varios ficheros y cargarlos con <script> no soluciona nada: todos comparten el mismo espacio global, el orden de carga se convierte en una dependencia invisible que nadie ha escrito, y basta que dos ficheros declaren const PESOS para que la aplicación se caiga. En esta lección aprenderás el sistema de módulos de JavaScript: fronteras explícitas entre ficheros, con export para declarar qué se ofrece e import para declarar qué se necesita. Al final habrás reorganizado el proyecto entero en seis ficheros reales, con un grafo de dependencias que se puede dibujar.

Contenido

  1. El problema: ámbito global, orden y dependencias invisibles
  2. export: exportaciones con nombre
  3. import: traer lo que necesitas
  4. Renombrar con as e importar todo con * as
  5. export default
  6. Reexportar: el fichero de barril
  7. Ámbito propio y ejecución única
  8. <script type="module"> y qué cambia
  9. import() dinámico y await a nivel de módulo
  10. CommonJS: require y module.exports
  11. Node y "type": "module"
  12. Dependencias circulares
  13. Nómada Tareas, reorganizado
  14. Errores Comunes y Consejos
  15. Ejercicios
  16. Conclusión

  1. El problema: ámbito global, orden y dependencias invisibles

Supongamos que partes el fichero grande en tres y los cargas como aprendiste en el Módulo 1:

<script src="js/constantes.js"></script>
<script src="js/tarea.js"></script>
<script src="js/app.js"></script>

Esto tiene tres problemas serios, y ninguno da un mensaje de error claro.

Problema 1: todo comparte el mismo ámbito global. Cada <script> clásico ejecuta su código en el ámbito global, así que todas las declaraciones de nivel superior conviven en el mismo espacio de nombres.

// js/constantes.js
const PESOS = { alta: 3, media: 2, baja: 1 };

// js/formato.js
const PESOS = { negrita: 700, normal: 400 };
// ✗ SyntaxError: Identifier 'PESOS' has already been declared

Dos ficheros escritos por dos personas distintas, cada uno perfectamente razonable, y la aplicación no arranca. Con var sería aún peor: no habría error, el segundo pisaría al primero en silencio y el cálculo del esfuerzo daría NaN en algún sitio inesperado.

Problema 2: el orden importa y no está escrito en ninguna parte. Si alguien mueve el <script> de tarea.js por encima del de constantes.js, la clase Tarea intentará usar PESOS antes de que exista. El HTML se convierte en un fichero de configuración crítico que nadie documenta.

Problema 3: las dependencias son invisibles. Abre tarea.js y dime de qué depende. No puedes saberlo sin leerlo entero y buscar los identificadores que no declara. No hay ninguna línea que diga «este fichero necesita PESOS y ErrorDeValidacion».

El apaño clásico, antes de que existieran los módulos, era el patrón módulo de 03-04: encerrarlo todo en una función que se invoca a sí misma y colgar del global una sola variable.

var NomadaTareas = (function () {
  const PESOS = { alta: 3, media: 2, baja: 1 };   // privado gracias al closure
  class Tarea { /* … */ }
  return { Tarea };                                // única cosa pública
})();

Funciona —era la mejor práctica durante años— pero sigue sin resolver el orden ni hacer explícitas las dependencias, y basta que dos librerías elijan el mismo nombre global para volver al problema 1. Desde ES2015, el lenguaje trae la solución de serie.

  1. export: exportaciones con nombre

Un módulo es simplemente un fichero .js que usa export o import. Todo lo que declara es privado a ese fichero salvo lo que exporta explícitamente.

// util/fechas.js
export const HOY = '2026-09-20';

export function diasEntre(desde, hasta) {
  const ms = new Date(hasta) - new Date(desde);
  return Math.round(ms / (1000 * 60 * 60 * 24));
}

export function estaVencida(fechaLimite, estado, hoy = HOY) {
  return fechaLimite < hoy && estado !== 'hecha';      // R10
}

// Esta NO se exporta: es un detalle interno, invisible desde fuera
function normalizarFecha(texto) {
  return new Date(texto).toISOString().slice(0, 10);
}

Hay dos formas de escribir lo mismo. La primera es poner export delante de cada declaración, como arriba. La segunda es agrupar al final del fichero, lo que da una «tabla de contenidos» muy cómoda de leer:

// util/fechas.js — variante con lista de exportación
const HOY = '2026-09-20';
function diasEntre(desde, hasta) { /* … */ }
function estaVencida(fechaLimite, estado, hoy = HOY) { /* … */ }
function normalizarFecha(texto) { /* … */ }

export { HOY, diasEntre, estaVencida };     // normalizarFecha se queda dentro

Se puede exportar cualquier cosa con nombre: const, let, function, class. Y se puede renombrar en la propia exportación:

export { diasEntre as calcularDias, HOY as FECHA_REFERENCIA };

Las declaraciones export e import deben estar en el nivel superior del módulo, nunca dentro de un if, un bucle o una función. El motivo es que se analizan antes de ejecutar nada: eso permite al navegador descubrir el grafo de ficheros y descargarlos en paralelo, y es también lo que hace posible detectar errores de importación sin llegar a ejecutar el programa. Para importar condicionalmente existe import() dinámico (apartado 9).

  1. import: traer lo que necesitas

En el otro extremo, import declara qué se necesita y de dónde viene.

// modelo/tarea.js
import { HOY, estaVencida } from '../util/fechas.js';

export class Tarea {
  // …
  estaVencida(hoy = HOY) {
    return estaVencida(this.fechaLimite, this.estado, hoy);
  }
}

Las llaves de import { … } no son desestructuración, aunque se parezcan mucho a la de 04-07. Es una sintaxis propia del sistema de módulos: los nombres tienen que coincidir exactamente con los exportados, no admite valores por defecto ni patrones anidados, y se resuelve antes de ejecutar.

Sobre la ruta del módulo (el especificador), tres reglas que causan muchos tropiezos en el navegador:

Especificador Significado ¿Vale en el navegador?
'./fechas.js' Relativo a este fichero, misma carpeta
'../util/fechas.js' Relativo, subiendo un nivel
'/js/util/fechas.js' Absoluto desde la raíz del sitio
'fechas.js' Bare specifier (sin ./) No: el navegador lo interpreta como el nombre de un paquete

Y la más importante: en el navegador, la extensión .js es obligatoria. import { HOY } from './util/fechas' falla con un 404. Node en modo ESM también la exige. Es el error número uno de quien viene de otros entornos donde se puede omitir.

  1. Renombrar con as e importar todo con * as

Si dos módulos exportan algo con el mismo nombre, o si el nombre original choca con una variable local, se renombra al importar:

import { estaVencida as fechaVencida } from '../util/fechas.js';
import { estaVencida as tareaVencida } from './reglas.js';

console.log(fechaVencida('2026-09-05', 'pendiente'));   // true

Y si quieres todo el módulo agrupado bajo un nombre:

import * as fechas from '../util/fechas.js';

console.log(fechas.HOY);                                 // '2026-09-20'
console.log(fechas.diasEntre('2026-09-20', '2026-09-30'));   // 10
console.log(fechas.normalizarFecha);                     // undefined  ← no estaba exportada

Ese fechas es el objeto de espacio de nombres del módulo: contiene una propiedad por cada exportación. Tiene dos particularidades: está congelado (no puedes añadirle ni cambiarle nada) y sus propiedades son enlaces vivos, no copias, algo que verás en el apartado 7.

Forma Cuándo usarla
import { a, b } from '…' Lo normal: se ve de un vistazo qué se usa
import { a as x } from '…' Hay colisión de nombres o el original es poco claro aquí
import * as ns from '…' El módulo exporta muchas cosas relacionadas y el prefijo aporta claridad (fechas.diasEntre)
import '…' Solo interesa el efecto de ejecutarlo (registrar algo, cargar estilos); no se trae ningún nombre

  1. export default

Cada módulo puede tener una exportación por defecto, pensada para cuando el fichero representa una sola cosa.

// modelo/tablero.js
export default class Tablero { /* … */ }
// app.js
import Tablero from './modelo/tablero.js';       // sin llaves, y el nombre lo eliges tú
import ElTableroDelTaller from './modelo/tablero.js';   // también válido: es el mismo

La diferencia con las exportaciones con nombre:

Con nombre Por defecto
Cuántas por módulo Las que quieras Una
Sintaxis de importación import { X } from … import X from …
¿El nombre debe coincidir? No, lo pone quien importa
Errores de tecleo Se detectan al cargar Pasan desapercibidos
Autocompletado del editor Funciona bien Peor

Se pueden combinar en el mismo fichero:

// modelo/tarea.js
export default class Tarea { /* … */ }
export class TareaRecurrente extends Tarea { /* … */ }
export const ESTADOS = ['pendiente', 'en-curso', 'hecha'];
import Tarea, { TareaRecurrente, ESTADOS } from './modelo/tarea.js';

En este proyecto usaremos exportaciones con nombre en todos los ficheros. No es la única opción válida —muchos equipos usan default para la pieza principal de cada módulo—, pero las nombradas tienen dos ventajas prácticas: el nombre es el mismo en todo el código base, lo que hace que buscar Tablero encuentre todos los usos, y un error de tecleo salta al instante en lugar de dar undefined a mitad de la ejecución.

  1. Reexportar: el fichero de barril

Un módulo puede reexportar lo que importa de otros, sin llegar a usarlo. Sirve para ofrecer un punto de entrada único a un grupo de ficheros.

// modelo/index.js — un "barril"
export { Tarea, TareaRecurrente } from './tarea.js';
export { Tablero } from './tablero.js';
export { ErrorDeValidacion, ErrorDeDatos } from './errores.js';

// También se puede reexportar todo lo de un módulo:
export * from './reglas.js';

// O reexportar un default con nombre:
export { default as Formateador } from './formato.js';

Quien lo consume ve un único módulo:

import { Tarea, Tablero, ErrorDeValidacion } from './modelo/index.js';

Los barriles son cómodos, pero conviene una advertencia honesta: al importar del barril arrastras la carga de todos los ficheros que reexporta, aunque solo uses uno. Con seis ficheros es irrelevante; en proyectos grandes es una de las causas típicas de que el arranque se vuelva lento, y su relación con la división de código se trata en 09-05. Para un proyecto del tamaño de Nómada Tareas, importar directamente de cada fichero es más claro.

  1. Ámbito propio y ejecución única

Dos propiedades de los módulos que resuelven exactamente los problemas del apartado 1.

Cada módulo tiene su propio ámbito. Nada de lo que declaras es global, así que dos módulos pueden declarar const PESOS sin enterarse el uno del otro. Se acabó el problema 1.

// util/formato.js
const PESOS = { negrita: 700, normal: 400 };   // ✓ sin conflicto con el de reglas.js

Un módulo se ejecuta una sola vez, por muchas veces que se importe. La primera importación lo carga y ejecuta; las demás reciben el resultado ya calculado. Esto convierte a cada módulo en un singleton de facto:

// datos/contador.js
console.log('⚙ contador.js se está ejecutando');
export const registro = [];
export function anotar(texto) { registro.push(texto); }
// modelo/tablero.js
import { anotar } from '../datos/contador.js';
anotar('tablero cargado');

// modelo/tarea.js
import { anotar, registro } from '../datos/contador.js';
anotar('tarea cargada');
console.log(registro);   // [ 'tablero cargado', 'tarea cargada' ]  ← el MISMO array

En consola, '⚙ contador.js se está ejecutando' aparece una sola vez. Los dos módulos comparten el mismo registro porque comparten la misma instancia del módulo. Esa propiedad es utilísima —un módulo de configuración, una caché, un almacén compartido—, pero conviene tenerla presente: el estado que pongas a nivel de módulo es global para toda la aplicación, con todos los inconvenientes que eso tiene para las pruebas del Módulo 8.

Y un detalle fino: las importaciones son enlaces vivos, no copias. Si el módulo de origen reasigna una variable exportada, quien la importó ve el valor nuevo.

// contador.js
export let llamadas = 0;
export function incrementar() { llamadas += 1; }
// app.js
import { llamadas, incrementar } from './contador.js';
console.log(llamadas);      // 0
incrementar();
console.log(llamadas);      // 1   ← se actualizó solo
// llamadas = 5;            // ✗ TypeError: Assignment to constant variable

Las variables importadas son de solo lectura desde el módulo que importa: solo el módulo propietario puede cambiarlas. Es una forma de encapsulación que encaja perfectamente con lo que aprendiste en 05-03.

  1. <script type="module"> y qué cambia

Para que el navegador trate un fichero como módulo hay que decírselo:

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Nómada Tareas</title>
  <link rel="stylesheet" href="css/estilos.css">
</head>
<body>
  <h1>Nómada Tareas · Taller Nómada</h1>

  <!-- UN solo script: el punto de entrada. Los imports traen el resto. -->
  <script type="module" src="js/app.js"></script>
</body>
</html>

Un solo <script> para toda la aplicación: los import se encargan del resto, y el navegador descubre y descarga el grafo completo. Estas son las diferencias respecto a un script clásico:

Aspecto <script> clásico <script type="module">
Ámbito de las declaraciones Global compartido Propio del módulo
Modo estricto Solo con 'use strict' Siempre activo
Momento de ejecución Bloquea el HTML al descargar y ejecutar defer implícito: espera a que el HTML esté listo
Ejecuciones si se incluye dos veces Dos Una
import/export SyntaxError Permitidos
this en el nivel superior window undefined
Origen del fichero Cualquiera, incluido file:// Requiere HTTP y respeta CORS

El defer implícito es una buena noticia práctica: cuando llegues al Módulo 6 y empieces a buscar elementos de la página, el HTML ya estará completamente construido, sin necesidad de esperar a ningún evento. Y si necesitas lo contrario —que se ejecute en cuanto se descargue, sin esperar—, existe <script type="module" async src="…">.

La última fila es la que más quebraderos de cabeza da. Abrir el index.html con doble clic no funciona con módulos:

Access to script at 'file:///…/js/app.js' from origin 'null' has been blocked
by CORS policy: Cross origin requests are only supported for protocol schemes:
http, https, …

El motivo es de seguridad: los módulos se descargan con las mismas reglas que cualquier petición de red, y el protocolo file:// no tiene un origen válido. La solución es la que ya conociste en 01-02: levantar un servidor local.

# Con la extensión Live Server de VS Code: clic derecho → "Open with Live Server"

# Con Python, si lo tienes instalado
python3 -m http.server 8000

# Con Node
npx serve

Y después abrir http://localhost:8000, no la ruta del fichero. A partir de ahora, todo el desarrollo del proyecto se hace con un servidor local en marcha.

  1. import() dinámico y await a nivel de módulo

Las importaciones estáticas van al principio del fichero y se resuelven antes de ejecutar. Cuando necesitas cargar algo condicionalmente o en un momento concreto, existe la forma de función:

// import() se parece a una llamada a función y devuelve una promesa
const modulo = await import('./informes/exportadorPDF.js');
modulo.exportar(tablero);

Nos centramos aquí solo en la sintaxis, porque su uso principal —dividir la aplicación en trozos que se descargan bajo demanda para que arranque antes— es materia de 09-05, y porque lo que devuelve es una promesa, un objeto que estudiarás a fondo en 05-06. Retén tres diferencias:

import estático import() dinámico
Dónde puede escribirse Solo en el nivel superior En cualquier sitio: dentro de un if, de una función…
Cuándo se resuelve Antes de ejecutar el módulo En el momento en que se ejecuta la línea
La ruta puede ser variable No, debe ser un literal : import(\./idiomas/${codigo}.js`)`
Qué devuelve Nada (declara enlaces) Una promesa con el objeto de espacio de nombres

Ese await del ejemplo tampoco es habitual fuera de una función: en los módulos ES está permitido usar await directamente en el nivel superior, algo que se llama top-level await.

// datos/configuracion.js
const respuesta = await cargarConfiguracionSimulada();     // ✓ solo válido en un módulo ES
export const config = respuesta;

Cuando un módulo usa await en el nivel superior, todos los módulos que lo importan esperan a que termine antes de ejecutarse. Es una herramienta cómoda, pero tiene un coste real en el arranque de la aplicación, así que conviene usarla con cabeza. Volverás sobre ella en 05-06, cuando await deje de ser una palabra que aparece de pasada.

  1. CommonJS: require y module.exports

Antes de que los módulos ES existieran, Node.js inventó su propio sistema, llamado CommonJS, y sigue siendo omnipresente. Vas a encontrarlo constantemente, así que hay que reconocerlo.

// util/fechas.js — versión CommonJS
const HOY = '2026-09-20';

function diasEntre(desde, hasta) {
  return Math.round((new Date(hasta) - new Date(desde)) / 86400000);
}

module.exports = { HOY, diasEntre };
// o bien: exports.HOY = HOY;  exports.diasEntre = diasEntre;
// app.js — versión CommonJS
const { HOY, diasEntre } = require('./util/fechas.js');
const fechas = require('./util/fechas.js');       // el objeto entero

console.log(diasEntre(HOY, '2026-09-30'));        // 10

Aquí las llaves son desestructuración de verdad (04-07), porque require devuelve un objeto normal y corriente. Esa es la diferencia de fondo entre los dos sistemas:

ESM (import/export) CommonJS (require/module.exports)
Estándar Del lenguaje, desde ES2015 De Node.js
Cuándo se resuelve Estáticamente, antes de ejecutar En tiempo de ejecución, al llegar a la línea
Carga Asíncrona Síncrona (bloquea)
Dónde puede escribirse Solo en el nivel superior En cualquier sitio, incluido dentro de un if
Ruta variable No
Qué se importa Enlaces vivos Una copia del objeto en ese instante
Funciona en el navegador No sin herramientas
await en el nivel superior No
Extensión .js obligatoria No

La diferencia entre «enlace vivo» y «copia» tiene consecuencias visibles:

// contador.cjs
let llamadas = 0;
function incrementar() { llamadas += 1; }
module.exports = { llamadas, incrementar };
const { llamadas, incrementar } = require('./contador.cjs');
incrementar();
console.log(llamadas);      // 0  ← ¡copia del valor en el momento de importar!

Con ESM, ese mismo código imprimiría 1. Es una fuente clásica de confusión al migrar entre sistemas.

Cuál usar hoy: ESM en todo lo nuevo. Es el estándar del lenguaje, funciona en navegador y en Node, y permite el análisis estático del que dependen las herramientas de 09-05. CommonJS lo verás en proyectos con años, en muchos ficheros de configuración y en dependencias antiguas.

  1. Node y "type": "module"

En el navegador, type="module" en la etiqueta decide el sistema. En Node lo decide el package.json de la carpeta:

{
  "name": "nomada-tareas",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "inicio": "node js/app.js"
  }
}

Con "type": "module", Node interpreta todos los .js de ese proyecto como módulos ES. Sin esa línea (o con "type": "commonjs"), los interpreta como CommonJS. Y si necesitas mezclar, las extensiones mandan sobre el package.json:

Extensión Se interpreta como
.js Lo que diga "type" en el package.json más cercano
.mjs Siempre módulo ES
.cjs Siempre CommonJS

Con eso, ya puedes ejecutar el proyecto desde la terminal, algo muy cómodo para probar la lógica sin abrir el navegador:

cd nomada-tareas
npm run inicio          # o directamente: node js/app.js

Un aviso frecuente: en un módulo ES no existen require, __dirname ni __filename. Si los necesitas, hay equivalentes modernos, pero lo normal es no echarlos de menos.

  1. Dependencias circulares

Dos módulos que se importan mutuamente forman un ciclo. No es un error del lenguaje, pero suele acabar en undefined en el peor momento.

// modelo/tarea.js
import { Tablero } from './tablero.js';
export class Tarea {
  mover(destino) { return new Tablero(destino); }
}

// modelo/tablero.js
import { Tarea } from './tarea.js';
export class Tablero {
  agregar(datos) { return new Tarea(datos); }
}

Lo que pasa por dentro es que un módulo empieza a ejecutarse, encuentra el import, va al otro, y ese vuelve al primero —que aún no ha terminado de ejecutarse—. Con las class, que tienen zona muerta temporal (05-02), el resultado típico es un error del tipo Cannot access 'Tarea' before initialization.

flowchart LR
    A["tarea.js"] -->|"import { Tablero }"| B["tablero.js"]
    B -->|"import { Tarea }"| A
    A -.->|"⚠ ciclo"| A

Casi siempre un ciclo es una señal de diseño: dos módulos que se necesitan mutuamente en realidad son uno, o les falta un tercero. Las tres salidas habituales:

  1. Extraer lo común a un tercer módulo. Si ambos necesitan las constantes y los errores, sácalos a modelo/errores.js y modelo/reglas.js, y que los dos dependan de ellos.
  2. Invertir la dependencia. Que Tablero conozca a Tarea (agrega tareas), pero que Tarea no conozca a Tablero. Si una tarea necesita algo del tablero, que se lo pasen por parámetro.
  3. Fusionar los dos módulos si de verdad son inseparables.

En Nómada Tareas aplicamos la número 2, que es la que produce un grafo limpio: las capas de abajo no conocen a las de arriba.

  1. Nómada Tareas, reorganizado

Aquí está el proyecto entero repartido en sus módulos definitivos. Esta es la estructura sobre la que trabajarán todos los módulos siguientes del curso.

nomada-tareas/
├── index.html
├── package.json          ← { "type": "module" }
├── css/
│   └── estilos.css
└── js/
    ├── app.js            ← punto de entrada
    ├── modelo/
    │   ├── tarea.js      ← class Tarea, TareaRecurrente
    │   ├── tablero.js    ← class Tablero
    │   └── errores.js    ← ErrorDeValidacion, ErrorDeDatos
    ├── datos/
    │   └── backlog.js    ← el backlog canónico
    └── util/
        ├── fechas.js     ← HOY, diasEntre, estaVencida
        └── formato.js    ← marcas, plurales, tablas de texto

Y su grafo de dependencias:

flowchart TD
    APP["js/app.js<br/>punto de entrada"]
    TAB["modelo/tablero.js"]
    TAR["modelo/tarea.js"]
    BAC["datos/backlog.js"]
    ERR["modelo/errores.js"]
    FEC["util/fechas.js"]
    FOR["util/formato.js"]

    APP --> TAB
    APP --> BAC
    APP --> FOR
    APP --> ERR
    TAB --> TAR
    TAB --> ERR
    TAR --> ERR
    TAR --> FEC
    TAR --> FOR
    BAC --> TAR

Fíjate en que todas las flechas van hacia abajo: util/ y modelo/errores.js no dependen de nadie, modelo/tarea.js depende de ellos, modelo/tablero.js depende de la tarea, y app.js está arriba del todo. No hay ciclos, y cada fichero declara en sus primeras líneas exactamente de qué depende.

Los ficheros, ahora. Primero las hojas del grafo:

// js/util/fechas.js
export const HOY = '2026-09-20';

/** Días de diferencia entre dos fechas ISO (negativo si 'hasta' ya pasó). */
export function diasEntre(desde, hasta) {
  return Math.round((new Date(hasta) - new Date(desde)) / 86400000);
}

/** R10: vencida = fecha límite pasada y la tarea sin terminar. */
export function estaVencida(fechaLimite, estado, hoy = HOY) {
  return fechaLimite < hoy && estado !== 'hecha';
}

/** '2026-09-05' → '5 de septiembre de 2026' */
export function fechaLegible(iso) {
  const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
                 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
  const [anio, mes, dia] = iso.split('-');
  return `${Number(dia)} de ${MESES[Number(mes) - 1]} de ${anio}`;
}
// js/util/formato.js
export const MARCAS = Object.freeze({ pendiente: '○', 'en-curso': '▸', hecha: '✓' });
export const PESOS  = Object.freeze({ alta: 3, media: 2, baja: 1 });

export function marcaDeEstado(estado) {
  return MARCAS[estado] ?? '?';
}

export function plural(cantidad, singular, pluralForma) {
  return `${cantidad} ${cantidad === 1 ? singular : pluralForma}`;
}

/** Alinea un texto a la derecha con espacios, para tablas de consola. */
export function alinear(texto, ancho) {
  return String(texto).padStart(ancho, ' ');
}
// js/modelo/errores.js
export class ErrorDeValidacion extends Error {
  constructor(mensaje, campo, valorRecibido) {
    super(mensaje);
    this.name = 'ErrorDeValidacion';
    this.campo = campo;
    this.valorRecibido = valorRecibido;
  }
  describir() {
    return `[${this.name}] ${this.campo}: ${this.message}`;
  }
}

export class ErrorDeDatos extends Error {
  constructor(mensaje, causa) {
    super(mensaje);
    this.name = 'ErrorDeDatos';
    this.causa = causa;
  }
}

Ahora el modelo, que ya solo contiene lo que le corresponde:

// js/modelo/tarea.js
import { HOY, estaVencida as fechaVencida } from '../util/fechas.js';
import { PESOS, marcaDeEstado } from '../util/formato.js';
import { ErrorDeValidacion } from './errores.js';

const TRANSICIONES = Object.freeze({                    // R6
  pendiente: ['en-curso'],
  'en-curso': ['pendiente', 'hecha'],
  hecha: []
});

export class Tarea {
  #estado = 'pendiente';                                // R5
  #horas;

  constructor(datos) {
    if (typeof datos.titulo !== 'string' || datos.titulo.trim() === '') {
      throw new ErrorDeValidacion('El título no puede estar vacío.', 'titulo', datos.titulo);   // R2
    }
    this.id = datos.id;
    this.titulo = datos.titulo.trim();
    this.responsable = datos.responsable || null;       // R8
    this.prioridad = datos.prioridad ?? 'media';
    this.etiquetas = [...new Set((datos.etiquetas ?? []).map((e) => e.trim().toLowerCase()))];  // R9
    this.fechaLimite = datos.fechaLimite;
    this.revisor = datos.revisor ?? null;
    this.horasEstimadas = datos.horasEstimadas;         // pasa por el setter (R3)
    if (datos.estado !== undefined) {
      if (!Object.hasOwn(TRANSICIONES, datos.estado)) {
        throw new ErrorDeValidacion(`Estado desconocido: "${datos.estado}".`, 'estado', datos.estado);
      }
      this.#estado = datos.estado;
    }
  }

  get estado()         { return this.#estado; }
  get horasEstimadas() { return this.#horas; }
  get abierta()        { return this.#estado !== 'hecha'; }
  get esfuerzo()       { return (PESOS[this.prioridad] ?? 0) * this.#horas; }

  set horasEstimadas(valor) {
    if (typeof valor !== 'number' || !(valor > 0 && valor <= 40)) {
      throw new ErrorDeValidacion('Las horas deben estar entre 1 y 40.', 'horasEstimadas', valor);   // R3
    }
    this.#horas = valor;
  }

  estaVencida(hoy = HOY) {
    return fechaVencida(this.fechaLimite, this.#estado, hoy);
  }

  cambiarEstado(nuevo) {
    if (!(TRANSICIONES[this.#estado] ?? []).includes(nuevo)) {
      throw new ErrorDeValidacion(
        `Transición no permitida: "${this.#estado}" → "${nuevo}".`, 'estado', nuevo);   // R6
    }
    this.#estado = nuevo;
    return this;
  }

  descripcion(hoy = HOY) {
    const aviso = this.estaVencida(hoy) ? ' ⚠ VENCIDA' : '';
    return `${marcaDeEstado(this.#estado)} [${this.id}] ${this.titulo} · ${this.responsable ?? 'sin asignar'} · ${this.#horas} h${aviso}`;
  }

  toJSON() {
    return { id: this.id, titulo: this.titulo, responsable: this.responsable,
             prioridad: this.prioridad, estado: this.#estado, etiquetas: [...this.etiquetas],
             horasEstimadas: this.#horas, fechaLimite: this.fechaLimite, revisor: this.revisor };
  }

  static desdeJSON(datos) {
    return new Tarea(typeof datos === 'string' ? JSON.parse(datos) : datos);
  }
}

export class TareaRecurrente extends Tarea {
  constructor(datos) {
    super(datos);
    this.periodicidad = datos.periodicidad ?? 'semanal';
  }
  descripcion(hoy) {
    return `${super.descripcion(hoy)} · se repite ${this.periodicidad}`;
  }
}

Observa el import { estaVencida as fechaVencida }: la función de utilidad y el método de la clase se llaman igual, y as resuelve el choque sin renombrar ninguno de los dos. Es exactamente para esto que existe.

// js/modelo/tablero.js
import { Tarea } from './tarea.js';
import { ErrorDeValidacion } from './errores.js';

export class Tablero {
  #tareas = [];

  constructor(nombre, tareas = []) {
    this.nombre = nombre;
    for (const t of tareas) this.agregar(t);
  }

  get total()         { return this.#tareas.length; }
  get tareas()        { return [...this.#tareas]; }
  get abiertas()      { return this.#tareas.filter((t) => t.abierta); }
  get horasTotales()  { return this.#tareas.reduce((s, t) => s + t.horasEstimadas, 0); }
  get horasAbiertas() { return this.abiertas.reduce((s, t) => s + t.horasEstimadas, 0); }
  get esfuerzo()      { return this.#tareas.reduce((s, t) => s + t.esfuerzo, 0); }

  buscarPorId(id)    { return this.#tareas.find((t) => t.id === id) ?? null; }
  filtrar(predicado) { return this.#tareas.filter(predicado); }
  vencidas(hoy)      { return this.abiertas.filter((t) => t.estaVencida(hoy)); }

  horasPorResponsable() {
    return this.abiertas.reduce((acc, t) => {
      const clave = t.responsable ?? 'sin asignar';
      acc[clave] = (acc[clave] ?? 0) + t.horasEstimadas;
      return acc;
    }, {});
  }

  agregar(tarea) {
    if (!(tarea instanceof Tarea)) {
      throw new ErrorDeValidacion('Solo se admiten instancias de Tarea.', 'tarea', tarea);
    }
    if (this.buscarPorId(tarea.id)) {
      throw new ErrorDeValidacion(`Id duplicado: ${tarea.id}.`, 'id', tarea.id);      // R1
    }
    this.#tareas.push(tarea);
    return this;
  }

  cambiarEstado(id, nuevo) {
    const tarea = this.buscarPorId(id);
    if (tarea === null) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
    tarea.cambiarEstado(nuevo);
    return this;
  }

  resumen(hoy) {
    return { total: this.total, abiertas: this.abiertas.length,
             horasTotales: this.horasTotales, horasAbiertas: this.horasAbiertas,
             vencidas: this.vencidas(hoy).length, esfuerzo: this.esfuerzo };
  }
}
// js/datos/backlog.js
import { Tarea } from '../modelo/tarea.js';

/** Datos planos del backlog canónico de Taller Nómada. */
export const datosBacklog = [
  { id: 1, titulo: 'Rediseñar la sala polivalente',          responsable: 'Iván',  prioridad: 'alta',  estado: 'en-curso',  etiquetas: ['espacio', 'diseño'],               horasEstimadas: 12, fechaLimite: '2026-09-30', revisor: 'Marta' },
  { id: 2, titulo: 'Cartelería del taller de serigrafía',    responsable: 'Marta', prioridad: 'media', estado: 'pendiente', etiquetas: ['serigrafía', 'comunicación'],      horasEstimadas: 6,  fechaLimite: '2026-10-15', revisor: null },
  { id: 3, titulo: 'Actualizar la web de reservas',          responsable: 'Lucía', prioridad: 'alta',  estado: 'pendiente', etiquetas: ['web', 'reservas'],                 horasEstimadas: 14, fechaLimite: '2026-10-02', revisor: 'Iván' },
  { id: 4, titulo: 'Inventario de tintas de serigrafía',     responsable: 'Marta', prioridad: 'baja',  estado: 'hecha',     etiquetas: ['serigrafía', 'almacén'],           horasEstimadas: 3,  fechaLimite: '2026-09-12', revisor: null },
  { id: 5, titulo: 'Guía de encuadernación para residentes', responsable: 'Iván',  prioridad: 'media', estado: 'en-curso',  etiquetas: ['encuadernación', 'documentación'], horasEstimadas: 8,  fechaLimite: '2026-11-05', revisor: 'Lucía' },
  { id: 6, titulo: 'Presupuesto de la carpintería',          responsable: 'Iván',  prioridad: 'alta',  estado: 'pendiente', etiquetas: ['carpintería', 'compras'],          horasEstimadas: 5,  fechaLimite: '2026-09-05', revisor: 'Marta' }
];

/** Devuelve instancias nuevas cada vez: nadie comparte estado por accidente. */
export function crearBacklog() {
  return datosBacklog.map((datos) => new Tarea(datos));
}

Ese crearBacklog() como función, en lugar de un array exportado ya construido, es una decisión deliberada. Como los módulos se ejecutan una sola vez (apartado 7), un array exportado directamente sería el mismo para toda la aplicación, y una prueba que cambiara el estado de una tarea contaminaría a la siguiente. Devolviendo instancias nuevas en cada llamada, cada consumidor tiene las suyas.

Y por fin el punto de entrada, que ya no contiene lógica: solo orquesta.

// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY, fechaLegible } from './util/fechas.js';
import { plural, alinear } from './util/formato.js';
import { ErrorDeValidacion } from './modelo/errores.js';

const tablero = new Tablero('Taller Nómada', crearBacklog());

console.log(`— ${tablero.nombre} · ${fechaLegible(HOY)} —`);
for (const tarea of tablero.tareas) {
  console.log(tarea.descripcion(HOY));
}

const r = tablero.resumen(HOY);
console.log(`\n${plural(r.total, 'tarea', 'tareas')}, ${r.abiertas} abiertas`);
console.log(`Horas: ${r.horasAbiertas} abiertas de ${r.horasTotales} totales`);
console.log(`Vencidas: ${r.vencidas} · Esfuerzo ponderado: ${r.esfuerzo}`);

console.log('\nCarga por responsable:');
for (const [persona, horas] of Object.entries(tablero.horasPorResponsable())) {
  console.log(`  ${persona.padEnd(8)} ${alinear(horas, 3)} h`);
}

try {
  tablero.cambiarEstado(6, 'hecha');          // pendiente → hecha: prohibido (R6)
} catch (error) {
  if (error instanceof ErrorDeValidacion) console.error(`\n⚠ ${error.describir()}`);
  else throw error;
}

Ejecutándolo con node js/app.js (o abriendo index.html desde el servidor local y mirando la consola):

— Taller Nómada · 20 de septiembre de 2026 —
▸ [1] Rediseñar la sala polivalente · Iván · 12 h
○ [2] Cartelería del taller de serigrafía · Marta · 6 h
○ [3] Actualizar la web de reservas · Lucía · 14 h
✓ [4] Inventario de tintas de serigrafía · Marta · 3 h
▸ [5] Guía de encuadernación para residentes · Iván · 8 h
○ [6] Presupuesto de la carpintería · Iván · 5 h ⚠ VENCIDA

6 tareas, 5 abiertas
Horas: 45 abiertas de 48 totales
Vencidas: 1 · Esfuerzo ponderado: 124

Carga por responsable:
  Iván      25 h
  Marta      6 h
  Lucía     14 h

⚠ [ErrorDeValidacion] estado: Transición no permitida: "pendiente" → "hecha".

Los números canónicos de siempre, ahora producidos por seis ficheros con fronteras claras. Y lo más importante: si mañana hay que cambiar cómo se calcula el esfuerzo, sabes exactamente qué fichero abrir.

Errores Comunes y Consejos

  • Olvidar la extensión .js en el especificador. from './util/fechas' da un 404 en el navegador y un ERR_MODULE_NOT_FOUND en Node. La extensión es obligatoria.
  • Abrir el HTML con doble clic. Los módulos no funcionan sobre file:// por CORS. Servidor local siempre.
  • Confundir import { X } con desestructuración. No admite defectos, ni renombrado con :, ni patrones anidados; para renombrar se usa as.
  • Nombre mal escrito en una importación con nombre. Da SyntaxError: The requested module does not provide an export named 'Tabler'. Es un buen error: aparece al cargar, no a mitad de la ejecución.
  • Mezclar export default y con nombre sin criterio. Elige una convención por proyecto y respétala. En Nómada Tareas: siempre con nombre.
  • Poner un import dentro de un if o de una función. Es SyntaxError; para eso está import() dinámico.
  • Estado mutable a nivel de módulo. Como el módulo se ejecuta una vez, un export const cache = new Map() es global para toda la aplicación. A veces es justo lo que quieres; otras, un dolor de cabeza en las pruebas del Módulo 8. Exporta funciones fábrica cuando cada consumidor deba tener lo suyo.
  • Dependencias circulares. Si ves Cannot access 'X' before initialization, busca el ciclo. La solución casi nunca es un truco: es rediseñar para que las dependencias apunten en una sola dirección.
  • Barriles en proyectos grandes. Un index.js que reexporta cincuenta ficheros los carga todos aunque uses uno. Cómodo, pero con coste (09-05).
  • Consejo: ordena los import de cada fichero por capas —primero librerías externas, luego módulos del proyecto, luego los más cercanos—. Es la convención que aplican las herramientas de 08-02 y hace que el fichero se lea como una declaración de intenciones.

Ejercicios

Ejercicio 1 — Detectar el ciclo. Este proyecto no arranca. Dibuja el grafo, identifica el ciclo y propón una reorganización con un módulo nuevo.

// a/informe.js
import { Tablero } from '../b/tablero.js';
export function generarInforme(tareas) { return new Tablero('temp', tareas).resumen(); }

// b/tablero.js
import { formatearInforme } from '../a/informe.js';
export class Tablero {
  imprimir() { return formatearInforme(this.resumen()); }
}

Ejercicio 2 — Un módulo util/estadisticas.js. Crea un módulo que exporte media(numeros), maximo(objetos, campo) y agruparPor(objetos, campo), más una constante VERSION. Ninguna de las tres puede importar nada del modelo (deben servir para cualquier array de objetos). Después escribe un app.js que lo importe y calcule, sobre el backlog canónico: la media de horas de las tareas abiertas, la tarea de más horas y el agrupamiento por prioridad.

Ejercicio 3 — Contador compartido. Crea datos/metricas.js que exporte registrar(evento) y informe(), guardando el recuento en estado de módulo. Impórtalo desde modelo/tarea.js y desde modelo/tablero.js, registra un evento en cada cambio de estado y demuestra con una traza de consola que los dos módulos comparten el mismo contador. Después explica en dos líneas cuándo eso sería un problema.

Soluciones

Ejercicio 1

El ciclo es directo: informe.jstablero.jsinforme.js.

flowchart LR
    I["a/informe.js"] -->|"import Tablero"| T["b/tablero.js"]
    T -->|"import formatearInforme"| I

La causa de fondo es una inversión de capas: Tablero es una pieza del modelo y no debería saber nada de cómo se formatean los informes. La solución no es un truco de importación, sino ordenar las responsabilidades:

// util/formatoInforme.js — capa baja: no importa nada
export function formatearInforme(resumen) {
  return `${resumen.total} tareas · ${resumen.horasAbiertas} h abiertas · esfuerzo ${resumen.esfuerzo}`;
}

// modelo/tablero.js — capa media: solo depende de utilidades
export class Tablero {
  resumen() { /* … */ }
  // imprimir() desaparece: el tablero calcula, no presenta
}

// informes/informe.js — capa alta: conoce a las dos de abajo
import { Tablero } from '../modelo/tablero.js';
import { formatearInforme } from '../util/formatoInforme.js';

export function generarInforme(tareas) {
  return formatearInforme(new Tablero('temp', tareas).resumen());
}

Grafo resultante, sin ciclos y con todas las flechas en la misma dirección:

flowchart TD
    INF["informes/informe.js"] --> TAB["modelo/tablero.js"]
    INF --> FMT["util/formatoInforme.js"]
    TAB --> FMT

La lección general: cuando aparece un ciclo, casi siempre hay una capa que está mirando hacia arriba. Aquí era Tablero.imprimir(), un método de presentación en una clase de modelo.

Ejercicio 2

// js/util/estadisticas.js
export const VERSION = '1.0.0';

/** Media aritmética de un array de números. Devuelve 0 si está vacío. */
export function media(numeros) {
  if (numeros.length === 0) return 0;
  return numeros.reduce((s, n) => s + n, 0) / numeros.length;
}

/** Objeto con el valor máximo en el campo indicado, o null si el array está vacío. */
export function maximo(objetos, campo) {
  return objetos.reduce((mejor, actual) =>
    (mejor === null || actual[campo] > mejor[campo]) ? actual : mejor, null);
}

/** Agrupa por el valor de un campo: { alta: [...], media: [...] } */
export function agruparPor(objetos, campo) {
  return objetos.reduce((acc, obj) => {
    const clave = obj[campo] ?? 'sin valor';
    (acc[clave] ??= []).push(obj);
    return acc;
  }, {});
}
// js/app.js
import { crearBacklog } from './datos/backlog.js';
import { media, maximo, agruparPor, VERSION } from './util/estadisticas.js';

const backlog = crearBacklog();
const abiertas = backlog.filter((t) => t.abierta);

console.log(`estadisticas v${VERSION}`);
console.log(media(abiertas.map((t) => t.horasEstimadas)).toFixed(1));   // '9.0'  (45 / 5)
console.log(maximo(backlog, 'horasEstimadas').titulo);                  // 'Actualizar la web de reservas'

const porPrioridad = agruparPor(backlog, 'prioridad');
for (const [prioridad, tareas] of Object.entries(porPrioridad)) {
  console.log(`${prioridad}: ${tareas.length}`);
}
// alta: 3 · media: 2 · baja: 1

La restricción del enunciado —que no importe nada del modelo— es el punto del ejercicio. media, maximo y agruparPor funcionan igual con tareas, con residentes o con reservas de sala, y por eso viven en util/ y no dependen de nadie: son una hoja del grafo. Un módulo de utilidades que importa del modelo deja de ser reutilizable y es la semilla del próximo ciclo. Nota además que maximo devuelve el primer máximo en caso de empate, porque la comparación es estricta (>).

Ejercicio 3

// js/datos/metricas.js
const cuenta = new Map();          // estado de módulo: uno solo para toda la aplicación

export function registrar(evento) {
  cuenta.set(evento, (cuenta.get(evento) ?? 0) + 1);
}

export function informe() {
  return Object.fromEntries([...cuenta.entries()].sort((a, b) => b[1] - a[1]));
}

export function reiniciar() {      // imprescindible para las pruebas del Módulo 8
  cuenta.clear();
}
// js/modelo/tarea.js  (añadido)
import { registrar } from '../datos/metricas.js';

cambiarEstado(nuevo) {
  // …validación R6…
  this.#estado = nuevo;
  registrar(`tarea:${nuevo}`);
  return this;
}
// js/modelo/tablero.js  (añadido)
import { registrar } from '../datos/metricas.js';

cambiarEstado(id, nuevo) {
  // …
  tarea.cambiarEstado(nuevo);
  registrar('tablero:cambioEstado');
  return this;
}
// js/app.js
import { informe } from './datos/metricas.js';

tablero.cambiarEstado(2, 'en-curso');
tablero.cambiarEstado(3, 'en-curso');
tablero.cambiarEstado(1, 'hecha');

console.log(informe());
// { 'tablero:cambioEstado': 3, 'tarea:en-curso': 2, 'tarea:hecha': 1 }

Que el informe sume las llamadas hechas desde dos módulos distintos demuestra que ambos recibieron la misma instancia de metricas.js: el módulo se cargó y ejecutó una sola vez, y ese Map es único.

¿Cuándo es un problema? Cuando el estado compartido debería ser independiente. En las pruebas del Módulo 8, cada test heredaría los recuentos del anterior y fallaría de forma intermitente —de ahí que hayamos exportado reiniciar()—. Y si la aplicación mostrara dos tableros a la vez, las métricas de ambos se mezclarían sin poder separarlas. La alternativa es la misma que aplicamos en crearBacklog(): exportar una fábrica (crearMetricas()) para que cada consumidor tenga su instancia, y reservar el estado de módulo para lo que de verdad es global, como la configuración de la aplicación.

Conclusión

El proyecto ha dejado de ser un fichero para convertirse en una arquitectura. Los tres problemas del principio están resueltos por construcción: cada módulo tiene su propio ámbito, así que dos ficheros pueden declarar PESOS sin enterarse; el orden de carga lo deduce el motor a partir de los import, no el HTML; y las dependencias son explícitas, escritas en las primeras líneas de cada fichero, hasta el punto de que se puede dibujar el grafo y comprobar de un vistazo que todas las flechas apuntan hacia abajo.

Dominas la sintaxis completa. export delante de una declaración o agrupado en una lista al final; import { a, b } con nombres que deben coincidir —no es desestructuración—; as para renombrar en cualquiera de los dos extremos, como hiciste con estaVencida as fechaVencida para resolver un choque real; import * as ns para traer el espacio de nombres completo; export default para el caso de «este fichero es una sola cosa», con sus pros y sus contras frente a las nombradas; y la reexportación en barriles, cómoda pero con un coste de carga que conviene conocer. Sabes que un módulo se ejecuta una sola vez y funciona como un singleton, que las importaciones son enlaces vivos de solo lectura, y que por eso crearBacklog() es una función y no un array exportado.

En el lado del entorno tienes el cuadro completo: <script type="module"> con su ámbito propio, su modo estricto permanente, su defer implícito —que te vendrá muy bien en el Módulo 6— y su exigencia de servidor local por CORS; import() dinámico y await a nivel de módulo como sintaxis que retomarás en 05-06 y 09-05; CommonJS con require/module.exports para reconocerlo cuando aparezca, con su tabla de diferencias frente a ESM —resolución estática frente a dinámica, enlaces vivos frente a copias, navegador sí o no—; y el "type": "module" del package.json que permite ejecutar el proyecto con node js/app.js. Y sabes diagnosticar una dependencia circular: no como un problema de importación que se arregla con un truco, sino como el síntoma de una capa que está mirando hacia arriba.

Nómada Tareas vive ahora en modelo/tarea.js, modelo/tablero.js, modelo/errores.js, datos/backlog.js, util/fechas.js, util/formato.js y js/app.js, y sigue dando los mismos números de siempre: 48 h totales, 45 abiertas, 1 vencida, esfuerzo 124, Iván con 25 h, Lucía con 14 y Marta con 6. Con una diferencia importante: ese datos/backlog.js es hoy un array escrito a mano, y todo el mundo sabe que en la aplicación real los datos vendrán de algún sitio —un fichero, un servidor— y tardarán en llegar. Un lenguaje de un solo hilo no puede quedarse esperando de brazos cruzados mientras eso ocurre, porque mientras espera la interfaz entera se congela. Cómo se programa una espera sin bloquear nada es el otro gran salto del curso, y empieza en JavaScript Asíncrono: Callbacks.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados