Detectando imports circulares com Madge (e travando regressões no lint)
Quem já passou horas caçando um undefined no meio de um bundle ou um comportamento estranho só 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.
Eu já vi isso em projeto pequeno e em projeto grande; a diferença é só o tamanho do grafo quando você finalmente desenha com o Madge.
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 imports circulares (sem frescura)
Em módulos ECMAScript, cada arquivo é avaliado uma vez, na ordem em que o grafo de dependências manda. Quando há um ciclo, parte do código pode rodar antes do vizinho 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.
Ciclos, pra mim, também são cheiro 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 (exemplos que eu já vi na vida)
Não precisa ser malícia. Na maioria das vezes é conveniência que virou débito.
1. O “barril” que virou ponte rolante
Você cria components/index.ts pra exportar tudo bonitinho. Só que dentro de um componente filho você importa de @/components em vez de caminho relativo. O barril importa o filho; o filho importa o barril. Ciclo na hora.
// components/Button/KebabButton.tsx
import { Button } from '@/components' // ❌ aponta pro barril…
// components/index.ts
export { KebabButton } from './Button/KebabButton' // …que aponta de volta
O que eu faço: de dentro da pasta Button, importar Button de ./index ou de ./Button (irmão), não do mega-index da raiz de components. O barril é prático pra quem consome de fora; por dentro do módulo, prefiro caminho curto e local.
2. constants.ts ↔ types.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.
3. Query keys e hook de API (TanStack Query e afins)
Muito time deixa 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 não funciona.
// 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.
4. 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 typequando 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 igualimport type“puro”; leia a doc do teu bundler/Biome.- Barris pequenos: um
index.tspor 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 keyfactory 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) ounoImportCycles(Biome) no CI: eu trato como cinto de segurança. Se o projeto já nasce ciclado, começar comwarne ir fechando os piores é estratégia válida — fica emerrorquando 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).
- Ciclos com
--circular. - Coisas úteis pra auditoria:
summary,depends,orphans,leaves,--jsonpra script. - Figura com
--image— mas no Madge 8 isso costuma pedir Graphviz na máquina (brew install graphvizno macOS, por exemplo).
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.
