fundamentos-javascript

Módulos ES: import, export y la diferencia entre valor y tipo

Las primeras líneas de cualquier archivo esconden dos cosas que se escriben casi igual pero no sobreviven igual al build: los valores llegan al JavaScript final, los tipos se quedan en el camino. Entender esa diferencia explica el import type sobre una clase que compila sin quejas y falla al ejecutar, y también por qué aparecen los ciclos de importación.

Cesar Enrique Manzano Velasco
Cesar Enrique Manzano Velasco
16 de agosto de 2026

Este es el primer artículo de la serie sobre el lenguaje, y empieza por donde empieza cualquier archivo del proyecto: la lista de imports de las primeras líneas. Ahí conviven dos cosas que se escriben casi igual pero que se comportan de forma distinta: los valores, que existen cuando el programa corre, y los tipos, que desaparecen al compilar. Entender esa diferencia explica varios errores que de otro modo parecen arbitrarios, incluidos los ciclos de importación.

Un módulo es un archivo, y lo que no se exporta no sale

Antes de hablar de tipos conviene fijar el concepto base. En módulos ES cada archivo es un módulo con su propio ámbito: nada de lo que se declara adentro es visible desde fuera a menos que se exporte de forma explícita.

ts

// contador.ts const secreto = 42; // solo existe dentro de este archivo export const visible = 'hola'; // esto sí sale

ts

// otro.ts import { visible } from './contador'; // import { secreto } from './contador'; → error: no está exportado

Esa es toda la mecánica. Lo demás son variantes de cómo se exporta y de qué se está exportando.

export con nombre frente a export default

Un módulo puede exportar tantos elementos con nombre como necesite. El que importa debe usar exactamente ese nombre, salvo que lo renombre a propósito con as.

ts

export function mapToClaim(req: ClaimRequest): Claim { /* ... */ } export const MAX_REINTENTOS = 3;

ts

import { mapToClaim, MAX_REINTENTOS } from 'entrypoints/mappers/mappers'; import { mapToClaim as mapear } from 'entrypoints/mappers/mappers';

La exportación por defecto es distinta: solo puede haber una por archivo y no lleva nombre propio. El nombre lo pone quien importa, y puede ser cualquiera.

ts

// infrastructure/database/pg_manager.ts export default PgManager.getInstance();

ts

import pgManager from 'infrastructure/database/pg_manager'; import loQueSea from 'infrastructure/database/pg_manager'; // también compila

Esa libertad es justo lo que la hace incómoda en un proyecto con varias personas: el mismo objeto puede aparecer con tres nombres distintos en tres archivos. Las exportaciones con nombre, en cambio, se renombran solas cuando el editor hace un refactor, y el autocompletado las encuentra sin que haya que recordar de qué archivo salían.

Convención razonable: exportaciones con nombre por defecto, y export default únicamente cuando el archivo entero representa una sola cosa —una instancia singleton, un componente, un handler—. Lo importante no es cuál se elija, sino que el criterio sea el mismo en todo el repositorio.

Valor y tipo: dos mundos en la misma lista de imports

Aquí está el concepto central del artículo. Un import puede traer dos cosas de naturaleza muy distinta:

  • Un valor es algo que existe cuando el programa corre: una función, una constante, una clase, una instancia.

  • Un tipo es una anotación para el compilador: una interface, un type, la firma de algo. No existe en el JavaScript que se ejecuta.

Estas dos líneas se parecen mucho, pero no sobreviven igual al build:

ts

import { sendToQueue } from 'services/sqs.service'; // valor: existe en runtime import type { Claim } from 'domain/models/claim'; // tipo: se borra al compilar

Al compilar, el resultado deja claro qué pasó con cada una:

js

const sqs_service_1 = require("services/sqs.service"); // la línea de Claim no aparece por ningún lado

TypeScript elimina los imports que solo se usaron como tipos. A eso se le llama elisión. Y como lo hace solo, cabe la pregunta razonable de para qué sirve escribir import type si el compilador ya se da cuenta.

La respuesta tiene que ver con quién compila. Cuando el proyecto se empaqueta con una herramienta que procesa cada archivo por separado —esbuild, swc, el transpilador de Vite—, esa herramienta no tiene el proyecto entero en la cabeza: ve un archivo, ve un import y tiene que decidir si lo conserva o lo borra sin saber qué hay del otro lado. import type le quita la duda. Marcarlo de forma explícita también evita arrastrar sin querer los efectos secundarios de un módulo que solo se necesitaba por su tipo.

Existe además la forma en línea, útil cuando del mismo archivo se necesitan las dos cosas:

ts

import { type Claim, mapToClaim } from 'domain/models/claim';

Y su equivalente al exportar:

ts

export type { Claim, Insured };

Detalle para mirar dos veces: class y enum son valor y tipo al mismo tiempo. BusinessError se puede usar como anotación (error: BusinessError) y también instanciar (new BusinessError(...)). Por eso una clase nunca debe importarse con import type si en algún punto se va a instanciar o se va a usar en un instanceof: la línea desaparecería del build y el código fallaría al ejecutarse.

El atajo para decidir: basta preguntarse si esa línea tiene que hacer algo cuando el proceso arranca. Si el nombre importado solo aparece después de dos puntos, dentro de <> o a la derecha de un implements, es un tipo y merece import type. Si aparece detrás de un new, con paréntesis de llamada o en un instanceof, es un valor y tiene que quedarse.

Re-exports: el índice de una carpeta

Un módulo puede reexportar lo que otro exporta, sin llegar a usarlo. Sirve para armar el índice de una carpeta y ofrecer un único punto de entrada.

ts

// entrypoints/mappers/index.ts export { mapToClaim } from './mappers'; export { mapToDocument } from './document.mappers'; export type { ClaimRequest } from './types';

ts

// quien lo consume ve una sola ruta import { mapToClaim, mapToDocument } from 'entrypoints/mappers';

La ventaja es que la organización interna de la carpeta puede cambiar sin romper a nadie: mientras el índice siga exportando los mismos nombres, mover archivos adentro es invisible desde fuera. El coste es que un índice hace que importar una sola función arrastre la evaluación de todo lo que el índice enumera, y que los ciclos sean más fáciles de crear sin darse cuenta.

Al reexportar tipos conviene usar export type de forma explícita, por la misma razón que en los imports: el empaquetador que procesa archivo por archivo no puede adivinar que ClaimRequest es solo una anotación.

Ciclos de importación: cómo aparecen y por qué duelen

Un ciclo ocurre cuando dos módulos se importan mutuamente, directa o indirectamente. Casi nunca se escribe a propósito: aparece al añadir «una cosita» a un archivo que ya era importado por el otro.

ts

// claim.ts import { validarInsured } from './insured'; export class Claim { /* ... */ }

ts

// insured.ts import { Claim } from './claim'; // ← el ciclo se cierra aquí export function validarInsured(c: Claim) { /* ... */ }

El síntoma típico no es un error de compilación, sino un fallo en ejecución que parece imposible: un valor importado que llega como undefined, o un mensaje del estilo Cannot access 'X' before initialization. La explicación es que uno de los dos módulos empezó a evaluarse antes de que el otro terminara de definir lo que exportaba.

Hay tres formas de romperlo, en orden de preferencia:

  • Convertir el import en un import de tipo, si el nombre solo se usaba como anotación. Al desaparecer del build, el ciclo en tiempo de ejecución deja de existir. En el ejemplo anterior, insured.ts solo necesita Claim como tipo: con import type { Claim } el problema se acaba.

  • Extraer la pieza compartida a un tercer módulo del que dependan los dos, en lugar de que dependan entre sí.

  • Revisar la dirección de la dependencia. Muchos ciclos son la señal de que una capa está importando hacia afuera cuando debería importar hacia adentro: un modelo de dominio que termina conociendo a su repositorio, por ejemplo.

Que un ciclo solo exista entre tipos no lo vuelve inofensivo desde el punto de vista del diseño: sigue indicando que dos módulos están más entrelazados de lo que deberían. Simplemente no revienta en producción.

Conclusión

De todo lo anterior, la idea que más rinde a diario es la separación entre valor y tipo. Los valores viajan al JavaScript final; los tipos se quedan en el camino, y por eso un import type mal puesto sobre una clase que después se instancia produce un error que el editor no marca y que solo aparece al ejecutar. La pregunta «¿esta línea tiene que hacer algo cuando el proceso arranca?» resuelve casi todos los casos.

Lo demás son decisiones de estilo con consecuencias reales: exportaciones con nombre para que los refactors y el autocompletado trabajen a favor, índices de carpeta cuando de verdad simplifican el consumo, y atención a los ciclos como síntoma de que la dirección de las dependencias se torció en algún punto.

Cesar Enrique Manzano Velasco

Sobre Cesar Enrique Manzano Velasco

Ingeniero Full Stack & AI/ML practitioner. Builder en AtaraxiaTech. Construyo microSaaS con IA en producción y lo cuento todo en público.