Comment détecter les dépendances circulaires dans votre projet TypeScript (et les corriger)
Les dépendances circulaires sont l'un des problèmes les plus frustrants dans les projets TypeScript. Elles provoquent des échecs de build mystérieux, des imports undefined au runtime, et des bugs quasi impossibles à tracer. Le pire ? Elles s'infiltrent silencieusement — un import anodin à la fois — jusqu'à ce que votre graphe de modules entier soit emmêlé.
Si vous avez déjà vu un TypeError: Cannot read properties of undefined dans un fichier qui exporte clairement une valeur, ou si votre bundler entre dans une boucle infinie, il y a de fortes chances que vous ayez affaire à une dépendance circulaire.
Ce guide couvre trois méthodes pratiques pour détecter les dépendances circulaires dans les projets TypeScript, du CLI manuel aux checks PR entièrement automatisés. Nous verrons aussi les patterns courants pour les corriger.
Pourquoi les dépendances circulaires sont dangereuses en TypeScript
TypeScript compile vers JavaScript, et le chargement de modules JavaScript est séquentiel. Quand le module A importe le module B, et que le module B importe le module A, le runtime doit résoudre ce cycle — et il le fait en retournant un module partiellement initialisé. Cela mène à :
1. Des exports undefined au runtime
// userService.ts
import { sendEmail } from './emailService';
export function createUser(name: string, email: string) {
const user = { id: crypto.randomUUID(), name, email };
sendEmail(user.email, 'Welcome!'); // fonctionne
return user;
}
export function getUserById(id: string) {
// ... logique de recherche
return { id, name: 'John', email: 'john@example.com' };
}
// emailService.ts
import { getUserById } from './userService'; // circulaire !
export function sendEmail(to: string, subject: string) {
console.log(`Envoi de "${subject}" à ${to}`);
}
export function sendUserReport(userId: string) {
const user = getUserById(userId); // peut être undefined !
sendEmail(user.email, 'Votre rapport');
}
Cela compile sans erreurs. TypeScript ne vous prévient pas. Mais au runtime, selon quel fichier est chargé en premier, getUserById peut être undefined quand emailService.ts essaie de l'utiliser.
2. Échecs de build et de bundling
Des outils comme Webpack, Vite et esbuild peuvent parfois gérer les cycles simples, mais les chaînes de dépendances circulaires complexes causent :
- Des boucles infinies pendant le tree-shaking
- Un code splitting incorrect
- Le Hot Module Replacement (HMR) qui casse silencieusement
- Des test runners qui échouent avec des erreurs cryptiques
3. Dégradation architecturale
Les dépendances circulaires sont un signal de couplage fort. Si le module A dépend du module B et vice versa, ils ne peuvent pas être testés, déployés ou compris indépendamment. Avec le temps, cela crée un "big ball of mud" où chaque changement risque de casser quelque chose ailleurs.
Méthode 1 : Détecter avec madge (CLI)
madge est un outil en ligne de commande qui crée des graphes de dépendances et trouve les dépendances circulaires. C'est le moyen le plus rapide de scanner votre projet.
Installation et utilisation
# Installer globalement ou en dépendance de développement
npm install -g madge
# ou
npm install --save-dev madge
# Trouver les dépendances circulaires dans un projet TypeScript
madge --circular --extensions ts,tsx src/
# Avec les alias de chemins tsconfig
madge --circular --extensions ts,tsx --ts-config tsconfig.json src/
# Générer un graphe visuel (nécessite Graphviz)
madge --circular --image graph.svg src/
Exemple de sortie
Circular dependencies found!
1) src/services/userService.ts > src/services/emailService.ts
2) src/models/Order.ts > src/models/Product.ts > src/models/Order.ts
3) src/utils/auth.ts > src/middleware/session.ts > src/utils/auth.ts
Ajout aux scripts package.json
{
"scripts": {
"check:circular": "madge --circular --extensions ts,tsx --ts-config tsconfig.json src/"
}
}
Limites de madge
- One-shot : vous le lancez manuellement ou en CI — il n'empêche pas les nouveaux cycles d'être introduits
- Pas d'enforcement : il rapporte les cycles mais ne bloque pas les PRs
- Configuration nécessaire : les alias de chemins, la résolution de modules et la config TypeScript demandent un setup manuel
- Pas de visualisation interactive : la sortie texte peut être difficile à lire pour les gros projets
Méthode 2 : ESLint plugin import/no-cycle
Le plugin eslint-plugin-import fournit une règle no-cycle qui attrape les dépendances circulaires pendant le développement.
Installation
npm install --save-dev eslint-plugin-import @typescript-eslint/parser
Configuration ESLint
Pour la configuration flat (eslint.config.mjs) :
import importPlugin from 'eslint-plugin-import';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
import: importPlugin,
},
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
},
},
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
rules: {
'import/no-cycle': ['error', { maxDepth: 5 }],
},
},
];
Pour le format legacy .eslintrc.json :
{
"parser": "@typescript-eslint/parser",
"plugins": ["import"],
"settings": {
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"import/no-cycle": ["error", { "maxDepth": 5 }]
}
}
Comment ça fonctionne
La règle trace les chaînes d'import jusqu'à maxDepth niveaux de profondeur. Quand elle détecte un cycle, elle rapporte une erreur :
src/services/emailService.ts
1:1 error Dependency cycle detected import/no-cycle
Limites
- Lent sur les gros projets : la règle trace chaque chaîne d'import, ce qui peut ralentir significativement le linting sur des codebases avec des milliers de fichiers
- Compromis maxDepth : une valeur élevée attrape plus de cycles mais est plus lente. Une valeur basse rate les cycles indirects
- Pas de vue architecturale : ça fonctionne fichier par fichier — pas de vue d'ensemble du graphe de dépendances
- CI seulement : ça attrape les cycles pendant le linting, mais ne fournit pas de dashboard visuel
Méthode 3 : Détection automatique sur chaque PR avec ReposLens
Les deux premières méthodes sont utiles mais réactives — vous les lancez manuellement ou en CI, et elles vous parlent de cycles qui existent déjà. ReposLens prend une approche différente : il détecte automatiquement les dépendances circulaires sur chaque pull request et bloque les PRs qui en introduisent de nouvelles.
Comment ça fonctionne
- Installez la GitHub App ReposLens sur votre repository (60 secondes)
- ReposLens analyse votre codebase et génère un graphe de dépendances interactif
- Sur chaque PR, il lance des checks d'architecture incluant la détection de dépendances circulaires
- Si une PR introduit un nouveau cycle, il poste un commentaire et bloque le merge
Ce que vous obtenez
- Graphe de dépendances interactif : explorez vos dépendances de modules visuellement — zoom, clic, filtre
- Bot PR avec Pass/Fail : chaque PR est vérifiée automatiquement. Aucune configuration CI nécessaire
- Règles d'architecture en YAML : définissez des règles custom au-delà du simple "pas de cycles" — enforcement de couches, limites de couplage, frontières de modules
- Score de santé : suivez la santé de votre architecture dans le temps, attrapez la dérive avant qu'elle devienne un problème
- Aucun stockage de code : ReposLens analyse en mémoire uniquement. Votre code source n'est jamais stocké (conforme RGPD)
Comparaison avec madge et ESLint
| Fonctionnalité | madge | ESLint no-cycle | ReposLens | |----------------|-------|-----------------|-----------| | Détecte les cycles | Oui | Oui | Oui | | Bloque les PRs | Non | Via CI | Natif | | Graphe interactif | Non | Non | Oui | | Temps de setup | 5 min | 10 min | 60 secondes | | Règles d'architecture custom | Non | Non | Oui | | Score de santé et suivi | Non | Non | Oui | | Impact sur les performances | Aucun | Ralentit le linting | Aucun |
Comment corriger une dépendance circulaire
Une fois les cycles identifiés, voici les patterns les plus courants pour les casser.
Pattern 1 : Extraire les types partagés dans un module séparé
La cause la plus courante de cycles est le partage de types entre deux modules :
// AVANT (circulaire)
// user.ts importe de order.ts, order.ts importe de user.ts
// APRÈS (corrigé)
// types.ts — types partagés, pas d'imports de user ou order
export interface UserRef {
id: string;
name: string;
}
export interface OrderRef {
id: string;
total: number;
}
// user.ts — importe de types.ts uniquement
import { OrderRef } from './types';
// order.ts — importe de types.ts uniquement
import { UserRef } from './types';
Pattern 2 : Inversion de dépendance (extraction d'interface)
Quand le module A doit appeler le module B et vice versa, introduisez une interface :
// AVANT (circulaire)
// notificationService.ts importe userService.ts
// userService.ts importe notificationService.ts
// APRÈS (corrigé)
// Définir l'interface dans un fichier séparé
export interface UserLookup {
getUserEmail(userId: string): Promise<string>;
}
// notificationService.ts dépend de l'interface, pas de l'implémentation
export class NotificationService {
constructor(private userLookup: UserLookup) {}
async notify(userId: string, message: string) {
const email = await this.userLookup.getUserEmail(userId);
// envoyer la notification
}
}
// userService.ts implémente l'interface mais n'importe pas NotificationService
export class UserService implements UserLookup {
async getUserEmail(userId: string): Promise<string> {
// logique de recherche
return 'user@example.com';
}
}
Pattern 3 : Déplacer la logique partagée vers un module utils/helpers
Parfois le cycle existe parce que deux modules partagent une fonction utilitaire :
// AVANT : auth.ts importe de session.ts, session.ts importe de auth.ts
// Les deux ont besoin d'une fonction `hashToken`
// APRÈS : extraire hashToken dans crypto-utils.ts
// crypto-utils.ts (pas d'imports circulaires)
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
// auth.ts importe de crypto-utils.ts uniquement
// session.ts importe de crypto-utils.ts uniquement
Pattern 4 : Éviter les barrel files (re-exports)
Les barrel files (index.ts) qui ré-exportent tout depuis un répertoire sont une source courante de cycles indirects :
// AVANT : index.ts crée des cycles indirects
// src/models/index.ts
export * from './User';
export * from './Order';
export * from './Product';
// Si User importe de Order et Order importe de ce barrel → cycle
// APRÈS : importer directement depuis les fichiers spécifiques
import { User } from './models/User';
import { Order } from './models/Order';
Conclusion
Les dépendances circulaires sont un problème structurel — elles signalent que vos frontières de modules fuient. Les trois méthodes couvertes ici forment un spectre :
- madge est excellent pour un audit rapide d'une codebase existante
- ESLint import/no-cycle attrape les cycles pendant le développement, mais ralentit le linting
- ReposLens empêche les cycles d'atteindre votre branche principale, sans aucune configuration CI
La meilleure approche dépend de la taille de votre équipe et de votre workflow. Pour les développeurs solo et les petites équipes, ReposLens offre le chemin le plus rapide vers un enforcement d'architecture continu — installation en 60 secondes, détection de cycles sur chaque PR, et suivi de la santé architecturale dans le temps.
Voir aussi : Comment détecter les dépendances circulaires dans votre codebase | ReposLens vs dep-cruiser