Módulos ES: import, export y la diferencia entre valor y tipo
Lección 1 de 43 · El lenguaje: de JavaScript a TypeScript estricto

De JavaScript a TypeScript estrictoLección 1/7
Temario del curso
De JavaScript a TypeScript estricto
Lección 1 de 7
CursoDe JavaScript a TypeScript estricto · Lección 1 de 7
Todo archivo de un proyecto TypeScript moderno es un módulo: una unidad aislada que declara qué expone al resto y qué necesita de fuera. Sobre esa base, TypeScript añade una distinción que no existe en JavaScript: hay importaciones que existen cuando el programa se ejecuta y otras que desaparecen al compilar.
Esta lección te explica cómo se organiza el código en módulos, qué diferencia hay entre importar un valor e importar un tipo, y por qué los ciclos de importación se rompen justo donde menos se espera.
Nota sobre la terminología: en este artículo, módulo siempre significa un archivo de TypeScript que usa import o export. Y runtime es el término que se usará de forma consistente para el tiempo de ejecución.
Qué es un módulo y por qué el aislamiento importa
Definición — Módulo: Un módulo es un archivo que encapsula su propio ámbito. Todo lo que declaras dentro es privado, salvo que lo exportes de forma explícita. Un archivo se considera módulo si contiene al menos un import o un export.
Antes de los módulos ES, todos los archivos JavaScript de una página compartían el mismo ámbito global. Una variable declarada en un archivo era visible desde cualquier otro. Si dos archivos usaban el mismo nombre, se pisaban en silencio. El problema no era estético, sino de escala: cuanto más crecía el proyecto, más difícil era saber quién dependía de quién.
Un módulo resuelve eso con una regla simple: todo lo que declaras dentro de un archivo es privado hasta que lo exportas.
ts
// contador.ts
let llamadas = 0; // privado: nadie fuera puede tocarlo
export function registrar(): number { // público: parte de la interfaz del módulo
llamadas += 1;
return llamadas;
}Desde otro archivo, llamadas no existe. Solo se ve lo que el módulo decidió publicar. Eso convierte la lista de exports de un archivo en su contrato con el resto del proyecto.
Importante: un archivo sin ningún
importniexportno es un módulo, sino un script, y sus declaraciones van al ámbito global. Si alguna vez un archivo "ve" variables que no importó, esa suele ser la causa.
Sintaxis básica de import y export
Definición — Export: export es la palabra clave que hace visible un símbolo fuera del módulo donde se declara. Un símbolo puede ser una variable, una función, una clase o un tipo.
Definición — Import: import es la palabra clave que trae a tu módulo símbolos exportados desde otro módulo.
Exportar un símbolo desde un archivo:
ts
// utilidades.ts
export const VERSION = '1.0';
export function formatear(texto: string): string {
return texto.trim();
}Importar ese símbolo en otro archivo:
ts
// app.ts
import { VERSION, formatear } from './utilidades';
console.log(VERSION);
console.log(formatear(' hola '));También puedes importar todo el módulo bajo un espacio de nombres:
ts
import * as util from './utilidades';
console.log(util.VERSION);Con esta base, pasemos a las dos formas de exportar y a cuándo conviene cada una.
Named exports frente a export default
Definición — Named export: Publica un símbolo con su nombre. Quien lo importa debe usar ese mismo nombre, salvo que lo renombre a propósito con as.
Definición — Default export: Publica un valor sin nombre asociado. Quien lo importa le pone el nombre que quiera.
La elección entre ambos tiene consecuencias prácticas, no solo de estilo.
Named exports
Un named export hace que el símbolo sea rastreable: buscar registrar en el proyecto encuentra tanto su declaración como todos sus usos.
ts
// mensajes.ts
export const SALUDO = 'hola';
export function normalizar(valor: string): string {
return valor.trim().toLowerCase();
}ts
// otro archivo
import { SALUDO, normalizar } from './mensajes';
import { normalizar as normalizarTexto } from './mensajes'; // renombrado explícitoExport default
Con un default, el mismo valor puede llamarse de tres formas distintas en tres archivos distintos. Ninguna herramienta puede avisarte de que son el mismo.
ts
// mensajes.ts
export default function normalizar(valor: string): string { /* ... */ }ts
// tres archivos, tres nombres, cero errores
import normalizar from './mensajes';
import limpiar from './mensajes';
import fn from './mensajes';La otra diferencia práctica está en el editor. Con named exports, puede sugerirte el símbolo mientras escribes y renombrarlo en todo el proyecto de una vez. Con un default, cada archivo eligió su propio nombre y el renombrado automático deja de funcionar.
Recomendación
En un proyecto con muchos archivos, la convención que mejor se sostiene es usar named exports por defecto. Reserva export default para dos casos:
-
Cuando una herramienta lo exige.
-
Cuando el módulo publica una sola cosa que además es su razón de ser.
ts
// configurar-app.ts
// Un solo valor, y el archivo existe por él.
export default function configurarApp() {
/* configuración */
}Un módulo puede combinar ambos, pero mezclarlos sin criterio genera dos sintaxis distintas para importar del mismo archivo. Conviene decidir una regla y aplicarla en todo el proyecto.
import type: lo que se borra al compilar
Hasta aquí hemos hablado de imports y exports en general. Ahora veremos una distinción que TypeScript añade y que no existe en JavaScript: la diferencia entre importar un valor e importar un tipo.
Antes hacen falta cinco definiciones.
Definición — Compilación: Proceso por el cual TypeScript transforma tu código en JavaScript. Durante ese paso, elimina toda la información de tipos.
Definición — Runtime: El momento en que el programa ya compilado se está ejecutando. Lo que ocurre en runtime es lo que existe en el JavaScript final.
Definición — Espacio de valores: Símbolos que existen en el JavaScript final y pueden usarse en líneas que se ejecutan: const, function, class, enum.
Definición — Espacio de tipos: Símbolos que solo existen durante la compilación para verificar tipos: interface, type y las anotaciones. Se borran por completo en la salida.
Definición — Interface y type: Una interface describe la forma de un objeto: qué propiedades y métodos debe tener. Un type da nombre a un tipo o a una combinación de tipos. Ninguno de los dos genera código JavaScript.
Los dos espacios, lado a lado
EspacioQué contiene¿Existe en runtime?¿Para qué sirve?Valoresconst, function, class, enumSíEjecutar código realTiposinterface, type, anotacionesNoSolo comprobar al compilar
Una importación normal no dice a cuál de los dos mundos pertenece lo que trae. El compilador lo deduce por cómo se usa y elimina de la salida lo que solo se usó como tipo. Escribir import type hace esa intención explícita.
Sintaxis
ts
// valor: existe en runtime
import { sendToQueue } from './services/queue.service';
// tipo: se borra al compilar
import type { Order } from './models/order';El JavaScript generado conserva la primera línea y elimina la segunda. En la salida no queda ninguna referencia a ./models/order.
También existe la forma en línea, útil cuando un mismo módulo publica ambas cosas:
ts
import { sendToQueue, type QueuePayload } from './services/queue.service';Y su equivalente al exportar:
ts
export type { Order };Por qué importa marcarlo
-
Evita importaciones con efectos secundarios accidentales. Importar un módulo lo ejecuta. Si un archivo solo se necesitaba por su
interface, pero el import se escribió como valor y el compilador no logra eliminarlo, ese módulo se evalúa igualmente. Eso incluye sus conexiones, sus lecturas de configuración y su coste de arranque, aunque nadie lo haya pretendido. -
Documenta la dependencia real. Un
import typedeja claro, al leer, que ese archivo no aporta comportamiento. Solo aporta forma. -
Es obligatorio con ciertas configuraciones. Algunas herramientas compilan archivo por archivo, sin ver el proyecto completo, y no pueden saber si un símbolo importado era un tipo o un valor. Si activas las opciones
isolatedModulesoverbatimModuleSyntax, habituales en proyectos que usan un bundler, el compilador te exige marcarlo de forma explícita.
Caso especial: class y enum
Una class y un enum viven en los dos espacios a la vez. Son un valor, porque existen en runtime, y también un tipo, porque se pueden usar en una anotación.
Por eso import type { AppError } compila mientras solo lo uses como anotación, pero falla en cuanto intentes new AppError(...) o error instanceof AppError. El import de tipo se borró, y en runtime ese nombre no existe.
El atajo para recordarlo
Hazte una sola pregunta: ¿este símbolo aparece en alguna línea que se ejecuta?
-
Si se llama, se instancia, se compara con
instanceofo se lee su valor, es un valor. -
Si solo aparece después de dos puntos, dentro de
<>o trasimplements, es un tipo, y mereceimport type.
Re-exports: armar índices de carpeta
Definición — Re-export: Un módulo importa símbolos de otro módulo y los vuelve a exportar, sin usarlos internamente.
Definición — Índice de carpeta: Un archivo, normalmente index.ts, que re-exporta los símbolos públicos de los módulos de esa carpeta, para que el resto del proyecto importe desde la carpeta y no desde archivos sueltos.
Cuando una carpeta agrupa varios módulos relacionados, obligar a quien los usa a conocer la ruta exacta de cada archivo acopla el resto del proyecto a la organización interna de esa carpeta. Mover un archivo rompe imports en sitios que no tenían por qué enterarse.
Un archivo índice resuelve ese problema: ofrece un único punto de acceso para importar desde esa carpeta.
ts
// domain/models/index.ts
export { Order } from './order';
export { Customer } from './customer';
export type { LineItem } from './line-item';
export * from './file-ref'; // re-exporta todo lo público
export { default as Conexion } from './conexion'; // convierte un default en nombradoCon eso, el resto del proyecto importa así:
ts
import { Order, Customer } from './domain/models';Reorganizar los archivos internos de domain/models pasa a ser un cambio de una sola línea en el índice.
Ten en cuenta:
export * from './x'es cómodo pero opaco. No se ve qué se está publicando sin abrir el archivo de origen, y si dos módulos exportan el mismo nombre, uno queda sin publicar en silencio. En índices que crecen, la lista explícita se lee mejor.
Ciclos de importación
Definición — Ciclo de importación: Ocurre cuando dos módulos se importan mutuamente, directa o indirectamente: a.ts importa de b.ts, y b.ts importa de a.ts.
El caso más frecuente en la práctica no es tan evidente. Aparece a través de un índice de carpeta, cuando un archivo importa desde su propio índice y ese índice lo re-exporta a él.
ts
// ❌ MAL: importar desde el índice dentro de la misma carpeta
// domain/models/order.ts
import { Customer } from './index'; // el índice re-exporta order.ts: hay ciclo
// ✅ BIEN: importar directamente del archivo
import { Customer } from './customer';Cuándo falla un ciclo y cuándo no
Al ejecutarse, un módulo se evalúa de arriba abajo una sola vez. Si durante esa evaluación llega a un módulo que ya está a medio evaluar, recibe lo que hubiera disponible en ese instante, que puede ser nada.
El comportamiento depende de cuándo se usa el símbolo importado. Hay dos casos:
Dónde se usa el símbolo¿Falla?Por quéDentro del cuerpo de una funciónNoCuando se llame a la función, ambos módulos ya terminaron de evaluarseEn el nivel superior del móduloSíSe lee antes de estar inicializado
Un uso en el nivel superior es, por ejemplo, una constante que se calcula al importar, una clase que extiende a otra o un decorador. El error que verás es este:
text
ReferenceError: Cannot access 'Customer' before initializationPor eso un ciclo se comporta como un bug intermitente. Puede pasar meses sin dar problemas y explotar el día que alguien mueve una constante al nivel superior o cambia el orden de los imports.
Las tres soluciones habituales
-
Importar del archivo concreto, no del índice, en los módulos que viven dentro de esa misma carpeta.
-
Convertir la dependencia en
import typecuando solo se necesitaba la forma. Al borrarse en la compilación, el ciclo desaparece de la salida por completo. Es la solución más limpia cuando aplica.
ts
// antes: ciclo real en runtime
import { Customer } from './customer';
// después: si solo se usaba como anotación, el ciclo se borra al compilar
import type { Customer } from './customer';- Extraer lo compartido a un tercer módulo del que dependan ambos, cuando el ciclo revela que había un concepto común sin nombre propio.
Que el ciclo desaparezca con import type no siempre significa que el diseño estaba bien. Si dos módulos se necesitan mutuamente incluso a nivel de tipos, suele ser señal de que las responsabilidades están mal repartidas.
Ejercicios
Ejercicio 1: clasificar imports
Indica cuáles de estos imports pueden convertirse en import type y cuáles no. Justifica cada caso.
Contexto: AppError es una clase que extiende Error. Order es una interfaz. sendToQueue y formatear son funciones.
ts
import { formatear } from './utils/texto';
import { AppError } from './errors/app-error';
import { Order } from './models/order';
import { sendToQueue } from './services/queue.service';
export function procesar(order: Order): Promise<string> {
if (!order.id) throw new AppError('ORDER_NOT_FOUND');
return sendToQueue(formatear(order.id), './cola');
}Ejercicio 2: convertir un default en named export
Un módulo exporta por defecto una función normalizar. Conviértelo a named export sin romper a quien ya lo consume: publica el nombre nuevo y mantén el default de forma temporal. Escribe cómo quedarían las dos líneas de export.
Ejercicio 3: índice de carpeta sin ciclo
Crea un índice para una carpeta con tres módulos: order.ts, customer.ts y line-item.ts.
-
order.tsnecesita el tipoCustomer. -
line-item.tsnecesita el tipoOrder. -
El índice debe publicar los tres.
-
Ningún archivo interno debe importar desde el índice.
Pista: fíjate en qué imports son solo de forma y cuáles necesitan existir en runtime.
Ejercicio 4: provocar y diagnosticar un ciclo
Construye a propósito un ciclo entre dos módulos donde el símbolo compartido se use en el nivel superior. Observa el fallo. Después mueve ese uso al cuerpo de una función y comprueba que el error desaparece. Explica en dos frases por qué cambió el resultado sin haber eliminado el ciclo.
Soluciones
Solución 1
Import¿import type?Por quéformatearNoSe llama dentro de la función: es un valorAppErrorNoSe usa con new: es un valorOrderSíSolo aparece como anotación del parámetrosendToQueueNoSe llama: es un valor
AppError es el caso interesante. Una clase vive en los dos espacios, así que podría importarse como tipo si solo se usara en anotaciones. En cuanto aparece un new o un instanceof, deja de poder serlo.
Solución 2
ts
// mensajes.ts
export function normalizar(valor: string): string {
return valor.trim().toLowerCase();
}
export default normalizar; // compatibilidad temporalLos consumidores pueden migrar poco a poco de import normalizar from './mensajes' a import { normalizar } from './mensajes'. Cuando no quede ninguno, se borra el default.
Solución 3
ts
// customer.ts
export class Customer {}
// order.ts
import type { Customer } from './customer'; // solo tipo: sin ciclo en runtime
export class Order {
cliente?: Customer;
}
// line-item.ts
import type { Order } from './order'; // solo tipo
export class LineItem {
pedido?: Order;
}
// index.ts
export { Order } from './order';
export { Customer } from './customer';
export { LineItem } from './line-item';Ningún archivo interno importa desde ./index, y los imports entre ellos son de tipo, así que no queda ningún ciclo en la salida.
Solución 4
Ciclo que falla, con uso en el nivel superior:
ts
// a.ts
import { b } from './b';
export const a = 'A';
console.log(b); // undefined, o ReferenceError
// b.ts
import { a } from './a';
export const b = 'B';El mismo ciclo, con el uso dentro de una función:
ts
// a.ts
import { b } from './b';
export const a = 'A';
export function mostrarB() {
console.log(b); // ahora sí: 'B'
}Explicación: al evaluarse a.ts no se accede a b, solo se define una función. Cuando más tarde se llama a mostrarB(), b.ts ya terminó de evaluarse y b está disponible.
Conclusión
Un módulo publica un contrato explícito, y en TypeScript ese contrato tiene dos capas que no pesan lo mismo: los valores sobreviven a la compilación y los tipos se borran.
Tener presente esa diferencia es lo que te permite predecir qué se ejecuta realmente, evitar importaciones con efectos secundarios que nadie pidió y desarmar la mayoría de los ciclos con un simple import type.
La pregunta que resume la lección es siempre la misma: ¿este símbolo aparece en una línea que se ejecuta?
Artículos relacionados

Variables, constantes y tipos primitivos
Con esta lección empieza el bloque del sistema de tipos, y el punto de partida es el más pequeño posible: declarar una variable. Parece trivial, pero la forma de declararla decide dos cosas distintas. Qué se puede reasignar, y qué tipo infiere el compilador. Ambas tienen consecuencias que se arrastran por el resto del código. Nota sobre la terminología: en este artículo, declarar significa crear una variable con let, const o var. Asignar significa darle un valor, y reasignar significa cambiar el valor al que apunta una variable ya declarada.

JSON.parse y la frontera sin tipos
Las cinco lecciones anteriores trataron datos que ya estaban dentro del programa. Esta trata del momento en que entran. JSON es el formato por el que llega casi todo lo externo: el cuerpo de una petición HTTP, el mensaje de una cola, la respuesta de un servicio de terceros, un archivo de configuración. Y JSON.parse es la puerta por la que pasa. Esa puerta tiene una particularidad incómoda: el compilador no puede comprobar nada de lo que la cruza, y por defecto tampoco avisa de que no puede. Nota sobre la terminología: en este artículo, serializar significa convertir un valor a texto JSON, y parsear significa el proceso inverso. Frontera del sistema es cualquier punto donde entran datos que el programa no generó.

Operadores de ausencia: ?., ?? y ??=
Lección 5 de 43 · El lenguaje: de JavaScript a TypeScript estricto