Skip to main content

Detectando imports circulares com Madge (e travando regressões no lint)

· 9 min read
Bruno Carneiro
Fundador da @TautornTech
Diagrama ilustrativo de um ciclo de imports entre módulos TypeScript

Quem já passou horas caçando um undefined no meio de um bundle ou um comportamento estranho em produção sabe como uma corrente de imports mal resolvida pode virar dor de cabeça. Um dos culpados frequentes é o import circular: o módulo A importa B, B importa C, e em algum lugar da cadeia alguém importa de novo o A — fechando um ciclo.

Isso é uma maravilha, o build funciona, o projeto roda local mas na hora de subir da pau! \o/

Eu já vi isso em projeto pequeno e em projeto grande; a diferença é só o tamanho do problema que você vai enfrentar. Da pra ver a dor de cabeça com o Madge e isso salva vida!

Pra travar o typescript é uma beleza circular import.

No dia a dia isso aparece como export “meio inicializado”, hook que devolve o que não era pra devolver, ou refatoração que parecia inofensiva e derrubou meio time. O pior é que o problema cresce sem critério quando rolam muitos index.ts que reexportam tudo, store que importa cliente HTTP e cliente que de alguma forma volta pra store ou pro mock, React Query com query-keys no mesmo barco que o hook que consome a query — padrão super comum.

O problema dos Circular Imports

Em módulos ECMAScript, cada arquivo é avaliado uma vez, na ordem em que o grafo de dependências expõe. Quando há um ciclo, parte do código pode rodar antes do outro pedaço terminar de definir o que você achava que já existia. Daí o clássico: “no Vite funcionou, no CI quebrou”, ou o contrário, depende de bundler, ordem e fase da lua.

Isso acontece muito quando exportações e importações repetidas, imagine utilitários fazendo export de seus métodos e outros utlitários exportando novamente isso. Que importar essa árvore vai ter problemas.

Ciclos assim, pra mim, também são problemas de arquitetura: camadas que deviam ser só “pra frente” (tipo UI → domínio → infra) acabam se apontando em círculo. Teste unitário vira novela, code split fica esquisito, e todo mundo tem medo de mexer no index.ts da pasta components.

Como isso costuma acontecer

Geralmente o projeto vai crescendo e falta de revisões de cada cama acaba comprometendo a estrutura. Sem contar export inocentes por ser mais rápido...

warning

Uma coisa que ando percebendo também que acredito que vai escalar e muito é o uso de IAs. Peguei alguns projetos onde ela programa loucamente e a falta de revisão do código adequadamente (delegando pra IA) gera esse tipo de problema.

Na minha opinião esse problema vai se tornar cada vez mais comum.

Também temaquele Barrel Import (eu adoro isso hehehe) com vários arquivos em um index.ts. Outra forma muito comum é devido aos alias no projeto (@components, @utils, @hooks...). Dentro de uma pasta components eu nunca deveria importar outro componente via alias, somente com o path absoluto.

Imagine a estrutura:

src/
└── components/
├── index.ts // exporta Button, Card, Modal
├── Button/
│ └── index.tsx
├── Card/
│ └── index.tsx
└── Modal/
└── index.tsx // usa Button dentro do Modal

Errado — importar via alias dentro da mesma pasta components:

// src/components/Modal/index.tsx
import { Button } from '@components' // 🔁 volta no barrel do index.ts

export function Modal() {
return <Button>Fechar</Button>
}

O que acontece: components/index.ts exporta Modal, e o Modal importa @components — que é o próprio index.ts. Pronto, ciclo fechado.

Certo — usar path relativo entre irmãos:

// src/components/Modal/index.tsx
import { Button } from '../Button' // caminho direto, sem passar pelo barrel

export function Modal() {
return <Button>Fechar</Button>
}

A regra mental: alias é pra quem consome o módulo de fora. Dentro do próprio módulo, caminho relativo evita voltar no barrel e quebra o ciclo antes dele nascer.

constants.tstypes.ts (ou hook ↔ types)

Parece inofensivo: constantes precisam de um tipo, o tipo precisa literal que está nas constantes. Dois arquivos se puxando o tempo todo.

// constants.ts
import type { TrainingMode } from './types'
export const MODES: TrainingMode[] = ['a', 'b']

// types.ts
import { MODES } from './constants'
export type TrainingMode = (typeof MODES)[number] // ❌ ciclo

O que eu faço: ou unir no mesmo arquivo (se for pequeno), ou extrair o literal base pra um terceiro arquivo só de “contrato”: training-mode.ts exporta o union string, constants e types importam dele, sem se importarem mutuamente.

Query keys e hook de API (TanStack Query e afins)

Muitas pessoas deixam query-keys.ts importar o hook (pra montar key factory com função do hook) e o hook importa as keys. Funciona até o dia que vira um spaghetti e tudo explode.

// query-keys.ts
import { fetchSpaces } from './use-get-all-spaces-query' // ❌

// use-get-all-spaces-query.ts
import { spaceKeys } from './query-keys'

O que eu faço: keys puras (strings, tuplas, as const) num arquivo que não importa hooks. O hook importa as keys; nunca o contrário. Precisou de tipo de retorno na key? import type de um tipo que mora em arquivo sem dependência de hook, ou inferência só no hook.

Store ↔ API ↔ interceptors

Store importa cliente; cliente ou interceptor importa store (auth token, feature flag, mock do MSW). Em duas três refatorações vira uma volta só — e o Madge mostra uma lista enorme que parece receita de bolo impossível.

O que eu faço: quem pode, desce pra camada baixa sem conhecer o store (ex.: passar getToken como função na criação do client). MSW e fixtures não deveriam importar metade do app “de produção”; separar dados fake de imports de UI salvou muito problema no passado.

Como evitar (e boas práticas que eu aplico)

Não existe mágica — é direção de dependência e menos super-arquivo:

  • Regra mental: se o arquivo é “infra” ou “módulo de domínio”, ele não importa tela nem barril gigante de componente. Se importa, desconfia.
  • import type quando for só tipo: ajuda o compilador e ajuda regras como a do Biome (ignoreTypes) a separar ciclo de runtime de ciclo só de tipo. Mas atenção: import { type X } misturado com valor não some igual import type “puro”; leia a doc do teu bundler/Biome.
  • Barrels pequenos: um index.ts por feature ou pasta, não um Deus-mãe-exporta-tudo que meio mundo importa por @/coisa.
  • Extrair o “contrato”: tipo compartilhado, constante literal base, query key factory sem lógica de rede — arquivos finos no meio do grafo quebram ciclo sem gambiarra.
  • Não importar “de volta” só por preguiça de path: alias @/ é ótimo pros consumidores externos ao módulo; internamente, relativo costuma ser mais honesto com quem depende de quem.
  • import/no-cycle (ESLint) ou noImportCycles (Biome) no CI: eu trato como cinto de segurança. Se o projeto já nasce ciclado, começar com warn e ir fechando os piores é estratégia válida — fica em error quando dá.

Madge: enxergar o monstro antes de debater no PR

O Madge mapeia o grafo de imports e ajuda a mostrar o problema (lista, JSON, gráfico).

Diagrama ilustrativo de um ciclo de imports entre módulos TypeScript
  1. Ciclos com --circular.
  2. Coisas úteis pra auditoria: summary, depends, orphans, leaves, --json pra script.
  3. Figura com --image — mas no Madge 8 isso costuma pedir Graphviz na máquina (brew install graphviz no macOS, por exemplo).

Esses dias eu gerei um gráfico que era imenso, tinha tanto import circular que eu sinceramente não sei como o projeto funcionava. Era mais de 50 arquivos fazendo isso. Foi trabalhoso arrumar porque mexia na arquitetura e em vários pontos sensívels mas o Madge ajudou a jogar luz nesse caos.

Na verdade o projeto já estava alarmando, como um bom carro movido a álcool no frio ele já estava lendo pra builde, o typescript levava minutos, o lint então... Mas como tudo na vida, enquanto não travou não corrigimos hahahaha.

Agora vamos pra umas execuções práticas, pra você já sair do artigo com o comando na mão.

TypeScript: não caia no “0 arquivos processados”

Se você rodar madge --circular ./src sem falar de .ts / .tsx e sem tsconfig, pode aparecer Processed 0 files e um “tudo certo” mentíroso. O que uso:

npx madge --circular --extensions ts,tsx --ts-config tsconfig.json ./src

Pra SVG só dos módulos envolvidos em ciclo (bem mais legível que o grafo inteiro):

npx madge --circular --extensions ts,tsx --ts-config tsconfig.json ./src --image graph-circular.svg

A imagem no topo aqui é ilustrativa; no teu artigo no teu site você pode trocar pelo export real do teu repo e pronto.

Fechando a torneira no lint

Madge eu uso pra descoberta e discussão; lint eu uso pra ninguém reabrir o buraco sem querer.

ESLint

Com eslint-plugin-import, a regra import/no-cycle:

// eslint.config.js (flat config) — trecho ilustrativo
import importPlugin from 'eslint-plugin-import';

export default [
{
plugins: { import: importPlugin },
rules: {
'import/no-cycle': ['error', { maxDepth: 10 }],
},
},
];

maxDepth você calibra pelo tamanho do repo e pelo tempo que o lint leva — não tem valor único universal.

Biome

Regra nativa noImportCycles no grupo suspicious:

{
"linter": {
"rules": {
"suspicious": {
"noImportCycles": {
"level": "error",
"options": {
"ignoreTypes": true
}
}
}
}
}
}

Conclusão

Import circular não é “vitamina” de quem usa TypeScript — é estrutura de grafo que você deixou dar uma volta completa. Os exemplos de cima são os que mais vejo no mundo real: barril confortável demais, par constants/types, keys vs hook, store vs client.

Ferramenta gráfica (Madge + Graphviz) ajuda a mostrar pro time onde está o nó; regra de lint ajuda a não regredir quando alguém resolve “só importar de lá que é mais perto”.

Arquitetura não é só pasta bonita — é seta de dependência com direção. Uma seta de cada vez, e o teu “undefined aleatório” some boa parte.

E cuidado com projetos escalando loucamente e sem revisão, ainda mais hoje em dia que muitos estão achando que programar é fácil... É só jogar pro Claudinho que ele se vira. Vai nessa.

Em produção quando der problema quem vai ser responsabilizado é você e não a IA então não negligencie arquitetura, boas práticas, revisão e testes bem feitos, entender o projeto que você tá mexendo, e claro, usar as ferramentas certas pra não deixar o monstro nascer.

Referências