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.
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í salets
// otro.ts
import { visible } from './contador';
// import { secreto } from './contador'; → error: no está exportadoEsa 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 compilaEsa 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, untype, 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 compilarAl 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 ladoTypeScript 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:
classyenumson valor y tipo al mismo tiempo.BusinessErrorse puede usar como anotación (error: BusinessError) y también instanciar (new BusinessError(...)). Por eso una clase nunca debe importarse conimport typesi en algún punto se va a instanciar o se va a usar en uninstanceof: 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 typede forma explícita, por la misma razón que en los imports: el empaquetador que procesa archivo por archivo no puede adivinar queClaimRequestes 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.tssolo necesitaClaimcomo tipo: conimport 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.
Artículos relacionados

Los pronombres personales sujeto y por qué en inglés nunca se omiten
Antes de conjugar ningún tiempo verbal hay que resolver una pregunta previa: quién hace la acción. El español permite callar esa información porque la terminación del verbo ya la lleva dentro; el inglés no lo permite casi nunca. Esta lección presenta los siete pronombres sujeto y explica por qué la omisión, que en español es elegante, en inglés produce una frase incorrecta. Nota sobre la terminología: en este artículo sujeto significa la palabra o grupo de palabras que indica quién realiza la acción del verbo. Pronombre significa la palabra que sustituye a un sustantivo ya conocido para no repetirlo.

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

Recorrer datos: map, filter, find, reduce y Object.*
Lección 4 de 43 · El lenguaje: de JavaScript a TypeScript estricto