Cómo detectar y corregir dependencias circulares en TypeScript

Al construir aplicaciones escalables con TypeScript en Angular, NestJS o Next.js, solemos enfocarnos en tipos fuertes, interfaces y una arquitectura modular. Sin embargo, existe un error silencioso que toma por sorpresa incluso a desarrolladores senior: las dependencias circulares (circular dependencies).
Escribes tu código, TypeScript compila sin ningún error, las pruebas parecen pasar, pero al ejecutar tu aplicación en producción, de repente te encuentras con esto:
TypeError: Cannot read properties of undefined (reading 'DarkTheme')
ReferenceError: Cannot access 'ButtonConfig' before initialization¿Por qué ocurre esto? Y más importante aún, ¿cómo podemos detectar y eliminar estos ciclos?
En esta guía te compartiré cómo se originan las referencias circulares, por qué la evaluación de módulos de JavaScript falla en tiempo de ejecución, cómo usar herramientas automáticas como Madge y los patrones exactos de refactorización (incluyendo import type) para mantener tu grafo de dependencias limpio y unidireccional.
El problema: por qué las dependencias circulares fallan en tiempo de ejecución
Para entender el peligro de una dependencia circular, primero debemos comprender cómo el runtime de JavaScript evalúa los módulos ES (import/export).
⚠️ El ciclo circular:
theme.ts(necesita ButtonConfig) ➔ importabutton.ts
button.ts(necesita Theme) ➔ importatheme.ts(¡Ciclo!)
Si el Módulo B intenta acceder de inmediato a una clase, constante o función de A durante el inicio (por ejemplo, al inicializar una configuración por defecto), ese valor todavía será undefined.
Note: TypeScript valida los tipos en tiempo de compilación, pero la ejecución de los módulos ES ocurre en tiempo de ejecución. Si quieres repasar cómo se comportan los tipos de TypeScript en runtime, revisa mi guía sobre cómo verificar tipos en TypeScript.
Veamos un escenario real y concreto donde ocurre este problema.
Escenario: La configuración de tema y botones
Imagina que estamos construyendo un sistema de componentes UI con temas personalizados y botones.
Decidimos crear theme.ts con definiciones de color y temas preestablecidos:
// theme.ts
export type Theme = {
color: string;
fontFormat: string;
};
export const DarkTheme: Theme = {
color: 'black',
fontFormat: 'italic',
};
export const LightTheme: Theme = {
color: 'white',
fontFormat: 'bold',
};Luego, para mantener nuestro código modular, creamos button.ts para manejar los componentes de botón y su configuración:
// button.ts
import { Theme } from "./theme";
export type ButtonConfig = {
label: string;
theme: Theme;
};Hasta aquí, button.ts importa desde theme.ts. La dirección de la dependencia es limpia y unidireccional.
Ahora, alguien en el equipo decide agregar una configuración predeterminada en theme.ts que incluye una lista preconfigurada de botones.
Veamos qué pasa cuando actualizamos theme.ts:
// theme.ts (Actualizado)
import { ButtonConfig } from "./button"; // ⚠️ ¡IMPORTACIÓN CIRCULAR CREADA!
export type Theme = {
color: string;
fontFormat: string;
};
export const DarkTheme: Theme = {
color: 'black',
fontFormat: 'italic',
};
export const defaultThemeConfig = {
type: 'dark',
buttons: [
{ theme: DarkTheme, label: 'Accept' },
{ theme: DarkTheme, label: 'Cancel' },
] as ButtonConfig[],
};Observa lo que acaba de suceder:
theme.tsahora importaButtonConfigdesdebutton.ts.button.tsimportaThemedesdetheme.ts.
Ambos archivos dependen el uno del otro. Cuando tu empaquetador o bundler (Vite, Webpack, esbuild o Node.js) carga button.ts primero, DarkTheme se evaluará como undefined, provocando que la aplicación falle en tiempo de ejecución.
Ahora veamos cómo podemos detectar estos ciclos de dependencias de forma automática antes de que lleguen a producción.
2. Detectar dependencias circulares con Madge y ESLint
En proyectos pequeños, quizás puedas notar referencias circulares de forma manual. Pero en proyectos empresariales con cientos de archivos, necesitas herramientas automatizadas.
Uso de la CLI de Madge
Madge es una excelente herramienta de código abierto que analiza dependencias entre módulos y visualiza referencias circulares.
Ejecuta este comando en el directorio de tu proyecto:
npx madge --circular --extensions ts ./srcSi existe una dependencia circular, Madge muestra el ciclo exacto:
✖ Found 1 circular dependency!
1) theme.ts > button.ts > theme.tsTambién puedes generar un gráfico visual interactivo con:
npx madge --image dependency-graph.svg --extensions ts ./srcPrevenir ciclos en CI/CD con ESLint
Para evitar que otros compañeros de equipo o futuros commits introduzcan importaciones circulares, puedes configurar la regla import/no-cycle en tu .eslintrc.json:
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5 }]
}
}Ahora que ya sabemos cómo identificar el problema, revisemos las tres mejores técnicas arquitectónicas para resolverlo.
3. Patrón 1: Extraer contratos compartidos (el módulo intermedio)
La forma más limpia de solucionar una dependencia circular es extraer los tipos y definiciones compartidas hacia un módulo independiente de menor nivel.
Creamos theme-config.ts para albergar la configuración combinada:
// 1. theme.ts (Definiciones base independientes)
export type Theme = {
color: string;
fontFormat: string;
};
export const DarkTheme: Theme = {
color: 'black',
fontFormat: 'italic',
};
export const LightTheme: Theme = {
color: 'white',
fontFormat: 'bold',
};// 2. button.ts (Solo depende de theme.ts)
import { Theme } from "./theme";
export type ButtonConfig = {
label: string;
theme: Theme;
};// 3. theme-config.ts (Une theme y button)
import { DarkTheme } from "./theme";
import { ButtonConfig } from "./button";
export type ThemeConfig = {
type: 'dark' | 'light';
buttons: Array<ButtonConfig>;
};
export const defaultThemeConfig: ThemeConfig = {
type: 'dark',
buttons: [
{ theme: DarkTheme, label: 'Accept' },
{ theme: DarkTheme, label: 'Cancel' },
],
};El nuevo grafo de dependencias limpio
theme.ts (Base independiente) ───► button.ts
│ │
▼ ▼
└─────────► theme-config.ts ◄─────┘¡El ciclo queda completamente roto! Cada módulo tiene una responsabilidad única y bien definida.
Ahora examinemos otra funcionalidad muy útil de TypeScript para ciclos que solo involucran tipos: import type.
4. Patrón 2: Usar importaciones de solo tipos (import type)
Muchas veces, las dependencias circulares ocurren únicamente porque un archivo necesita una interface o type de TypeScript para la comprobación de tipos, sin requerir código JavaScript en tiempo de ejecución.
Desde TypeScript 3.8, puedes utilizar importaciones de solo tipos (Type-Only Imports):
// button.ts
import type { Theme } from "./theme"; // 👈 ¡Importación de solo tipo!
export type ButtonConfig = {
label: string;
theme: Theme;
};Por qué import type elimina el ciclo en tiempo de ejecución
Cuando TypeScript compila este archivo a JavaScript, import type se elimina por completo del paquete final (Type Erasure o borrado de tipos):
// Salida de JavaScript compilado para button.ts
// Nota: ¡La importación de "./theme" NO existe en el bundle de JS!Si tu ciclo involucra exclusivamente tipos o interfaces de TypeScript, import type resuelve el problema de inmediato sin tener que crear nuevos archivos.
Tip: Si quieres evitar el uso de
anyy mantener tus tipos limpios, revisa mi artículo sobre por qué evitar usar 'any' en TypeScript.
Ahora pasemos a la tercera causa más común de dependencias circulares: los archivos barrel.
5. Patrón 3: Corregir ciclos causados por archivos barrel (index.ts)
Una trampa muy frecuente en proyectos modernos de frontend son los archivos barrel (reexportaciones en index.ts).
Imagina que tienes una carpeta components/ con su respectivo index.ts:
// components/index.ts
export * from './Button';
export * from './Card';
export * from './Modal';Si Card.tsx importa Button a través del archivo barrel:
// components/Card.tsx
import { Button } from './index'; // ❌ RIESGOSO: ¡Importa desde su propio barrel padre!Esto crea una dependencia circular indirecta: index.ts carga Card.tsx, el cual le pide Button a index.ts, antes de que index.ts haya terminado de exportar Button.
La solución: importaciones internas directas
Dentro de una misma carpeta de funcionalidad o módulo, utiliza siempre importaciones relativas directas:
// components/Card.tsx
import { Button } from './Button'; // ✅ SEGURO: Importación interna directaReserva los archivos barrel (index.ts) exclusivamente para consumidores externos fuera de ese directorio.
Matriz de decisión y resumen
| Escenario | Causa principal | Mejor solución |
|---|---|---|
| Ciclo solo de tipos | Dos archivos importan mutuamente sus type o interface | Usa import type { ... } from './file' |
| Ciclo de valores o runtime | Dos archivos importan mutuamente clases u objetos const | Extrae la lógica compartida a un tercer módulo (config.ts, models.ts) |
| Ciclo por archivo barrel | Un archivo interno importa a un hermano mediante index.ts | Cambia a una importación relativa directa (./Button) |
| Lógica de dominio compleja | Servicios de dominio fuertemente acoplados | Aplica Inversión de Dependencias (abstracción con interfaces) |
Conclusión y siguientes pasos
Las dependencias circulares en TypeScript pueden generar errores desconcertantes y silenciosos en tiempo de ejecución que pasan desapercibidos durante la compilación.
Si sigues estos cuatro principios:
- Usa
import typesiempre que solo necesites definiciones de tipos. - Estructura tus módulos con un flujo de dependencias unidireccional.
- Evita importar componentes hermanos a través de archivos barrel (
index.ts) internos. - Automatiza las revisiones con Madge y la regla
import/no-cyclede ESLint.
Podrás asegurarte de que tu arquitectura en TypeScript se mantenga limpia, modular y totalmente libre de trampas circulares en tiempo de ejecución.
Si quieres conocer más patrones prácticos en TypeScript, te invito a revisar estas guías relacionadas en mi blog:
Mastering the language of the web. From basic types to advanced generics and patterns.
Preguntas Frecuentes
¿Qué es una dependencia circular en TypeScript y por qué es peligrosa?
Una dependencia circular ocurre cuando el Módulo A importa al Módulo B, y el Módulo B importa directa o indirectamente al Módulo A. Aunque el compilador de TypeScript compile sin errores de sintaxis, en tiempo de ejecución Node.js o el navegador deben decidir qué módulo evaluar primero. El primer módulo evaluado recibirá una referencia no inicializada ('undefined') del otro módulo, provocando caídas en tiempo de ejecución como 'TypeError: Cannot read properties of undefined' o 'ReferenceError: Cannot access X before initialization'.
¿Cómo ayuda 'import type' en TypeScript a evitar errores de dependencias circulares en tiempo de ejecución?
Cuando una referencia circular solo existe para definiciones de tipos estáticos (interfaces, tipos), usar 'import type { MyType } from ./module' le indica al compilador de TypeScript que esta importación debe eliminarse por completo durante la compilación (type erasure). Al no emitir ninguna sentencia import en el código JavaScript final, el ciclo de dependencias en tiempo de ejecución desaparece por completo.
¿Qué herramientas pueden detectar dependencias circulares automáticamente en un proyecto de TypeScript?
Puedes usar herramientas CLI como Madge ('npx madge --circular --extensions ts ./') y DPDM ('npx dpdm --circular ./**.ts') para analizar tu código y visualizar árboles de dependencias. Para prevenir ciclos de forma continua en tu pipeline de CI/CD, puedes configurar la regla de ESLint 'import/no-cycle' de eslint-plugin-import.
¿Qué patrones de arquitectura ayudan a resolver dependencias circulares en proyectos grandes?
Los patrones más efectivos incluyen: 1) Extraer contratos compartidos en archivos independientes de tipos o modelos, 2) Aplicar Inversión de Dependencias para depender de abstracciones en lugar de implementaciones concretas, y 3) Refactorizar archivos barrel (index.ts) para que los módulos internos no importen desde su propio barrel padre.
Artículos Relacionados
Cómo comprobar tipos en TypeScript (Guía completa)
Aprende cómo comprobar y validar tipos en TypeScript en tiempo de compilación y en tiempo de ejecución usando typeof, instanceof, el operador in, uniones discriminadas, type guards personalizados y Zod o Valibot.
Por qué deberías evitar usar 'any' en TypeScript
Por qué usar any en TypeScript genera errores ocultos en tiempo de ejecución, y cómo reemplazarlo con unknown, guardas de tipo personalizadas, tipos de utilidad y el operador satisfies.
3 formas de transformar tipos en TypeScript
Cuando usamos tipos o interfaces en TypeScript, el compilador exige que el objeto coincida con ellos para evitar errores en tiempo de ejecución por propiedades faltantes. A veces queremos la flexibilidad de crear un objeto sin romper el contrato con la interfaz.
Cómo combinar tipos e interfaces con & y | en TypeScript
Aprende a componer tipos en TypeScript usando Union (|), Intersection (&) e interface extends con ejemplos prácticos, tablas de decisión y patrones reales.
Compartir este artículo
Si esta guía te resultó útil, compártela con tu equipo o con otros desarrolladores.
Software real. Lecciones reales.
Comparto las lecciones que aprendí por las malas, para que puedas evitarlas o estar preparado cuando ocurran.
Únete a 13,800+ desarrolladores y lectores.
Cero spam. Cancela tu suscripción en cualquier momento.