Skip to content
L-04Journal / ArticleEL +0.00 m
Developer Tooling18 min read

gen-import: Everything I Learned Building a Barrel Generator That Understands Your Module Graph

A full tour of gen-import: how it classifies every import edge as eager or deferred, models the generated barrel as a real node in the module graph, runs Tarjan's SCC to tell a broken cycle from a merely fragile one, and picks between four barrel-emission strategies depending on what it finds.

By Mohammed Mostafa · Published

TypeScriptNode.jsStatic AnalysisCompiler APIASTBarrel FilesCLI ToolingCommonJSGraph Theory

This is the long one — how gen-import reads your code, how it builds the graph, why it emits four different kinds of barrel depending on what it finds, every diagnostic it can produce, and — honestly — when you should not use it at all.

gen-import is on npm, MIT, and currently at v1.10.11. Node 16+. Three runtime dependencies: typescript, boxen, chalk.

terminalbash
npm i -D gen-import
npx gen-import

The 30-second version#

You have this in every file:

src/user.controller.tsts
import { UserService } from '../user/user.service'
import { UserDto } from '../user/user.dto'
import { authMiddleware } from '../middleware/auth.middleware'
import { PORT } from '../config/env'

You run npx gen-import and you get this:

src/user.controller.tsts
import { UserService, UserDto, authMiddleware, PORT } from './gen-import'

That part is easy. Any tool can do that with a regex and twenty lines. Everything else in this post exists because the easy version breaks real projects, and once I understood why, the tool stopped being a code generator and became a static analyser that happens to write a file at the end.

The pipeline#

Here's the whole thing, top to bottom. Every run does all of this.

gen-import-pipeline.mmdmermaid
flowchart TD
  A["walk(srcDir)"] --> B["filter: .d.ts, skipPatterns,<br/>pureReexports, generated files"]
  B --> C["split: regular files | module files<br/>(.module.ts .router.ts .routes.ts .route.ts)"]
  C --> D["createTsProgram — ONE ts.Program per run"]
  D --> E["analyzeFiles<br/>TypeChecker.getExportsOfModule<br/>→ type | value | default"]
  D --> F["scanFile → classify every import edge<br/>kind + eager? + eagerVia + line"]
  F --> G["buildModuleGraph<br/>+ contract the barrel as a real node"]
  G --> H["tarjanScc → SCCs → condensation → topoOrder"]
  H --> I["analyzeBarrel<br/>safe | type-safe | ordered | unsafe"]
  E --> I
  I --> J{"--safe-barrels?"}
  J -->|yes| K["selectSafeExports<br/>demote to types / drop values"]
  J -->|no| L["keep everything"]
  K --> M["diagnostics GI001–GI009"]
  L --> M
  M --> N{"emit strategy"}
  N --> O["static re-export (ESM)"]
  N --> P["lazy require getters (CJS)"]
  N --> Q["globals mode"]
  N --> R[".js + .d.ts pair (JS projects)"]
  M --> S{"--strict?"}
  S -->|blocking finding| T["exit 1"]
  S -->|clean| U["summary box + graph box"]

Six stages that matter: read → classify → graph → analyse → decide → emit. I'll take them in order.

Stage 1 — Reading your code (and why not regex)#

Everything goes through the TypeScript compiler API. One ts.Program is created per run and shared by both the export analyser and the graph builder, so files are parsed once, not twice.

The reason it's the compiler and not a fast hand-rolled parser is a single question I can't answer any other way: is UserDto a type or a value?

the type-vs-value questionts
export interface UserDto { id: string }   // type — erased at compile time
export class UserService {}                // value — exists at runtime
export const PORT = 3000                   // value
export type Role = 'admin' | 'user'        // type
export default router                      // value, needs an alias

The classification comes from TypeChecker.getExportsOfModule and the symbol flags: something carrying Interface or TypeAlias without the Value flag is type-only. Everything else is a value.

Get this wrong and you emit:

gen-import.tsts
export { UserDto } from './user/user.dto'

…which under isolatedModules, verbatimModuleSyntax, or literally any transpile-only loader becomes a real runtime import of a thing that doesn't exist after compilation. Crash, at import time, with a stack trace pointing at a generated file.

So types go out as export type { ... }, values as export { ... }, and default exports get an alias derived from the filename. A regex cannot tell you which bucket a name belongs in, because the answer lives in the type system, not the syntax.

Default exclusions, always on: .d.ts files, __tests__, .test., .spec., plus the generated files themselves (gen-import, gen-app-config, gen-package) — otherwise the barrel re-exports itself and you get an infinite loop of a very stupid kind.

Stage 2 — Classifying edges (the important part)#

This is the idea the whole tool is built on, and it took me embarrassingly long to arrive at.

A circular dependency is only a bug if something reads a binding while a module body is still executing.

If a.ts and b.ts import each other but only touch each other's exports inside function bodies, the cycle is completely harmless. Both modules finish initialising, and by the time anything is called, everything is defined. That's not a warning-worthy event — that's just how a lot of well-factored code looks.

So instead of a boolean "is there a cycle", every import edge gets classified: what kind of edge, is the binding read eagerly, and if so, via what.

edge-classification.mmdmermaid
flowchart TD
  A["import binding found"] --> B{"where is it read?"}

  B -->|"class X extends Y"| C1["EAGER · heritage"]
  B -->|"decorator argument"| C2["EAGER · decorator"]
  B -->|"static field / static block"| C3["EAGER · static"]
  B -->|"export * from './x'"| C4["EAGER · star-reexport"]
  B -->|"import './x' — side effect only"| C5["EAGER · side-effect"]
  B -->|"any other top-level statement"| C6["EAGER · module body"]
  B -->|"inside a function / method body"| D["DEFERRED — resolved at call time"]
  B -->|"type position only"| E["ERASED — not a runtime edge at all"]

  C1 --> F["counts toward INIT_EDGE_KINDS"]
  C2 --> F
  C3 --> F
  C4 --> F
  C5 --> F
  C6 --> F
  D --> G["in the graph, but harmless for init order"]
  E --> H["type graph only"]

INIT_EDGE_KINDS is the set of edge kinds that actually participate in module initialisation. Cycle analysis runs over that subgraph, not over the naive "file A mentions file B" graph. Dynamic import() and require() calls anywhere in the file are also tracked, but as deferred edges.

The payoff is that the tool can say things like: "Breaks at src/user/user.service.ts:14 — class heritage clause (class X extends Y)." instead of "Warning: circular dependency detected." One of those you fix. The other you learn to ignore, and then the tool has failed.

Stage 3 — The graph layer#

Once every edge is classified, the graph work is textbook, and I'm glad it is, because this is the part where being clever gets you subtle bugs.

  • tarjanScc — strongly connected components in one pass. Every SCC with more than one member (or a self-loop) is a cycle.
  • condensation — collapse each SCC into a single node, giving you a DAG.
  • topoOrder — topological order over that DAG. This is what determines the order of re-export lines in the barrel, so that for CommonJS the initialisation order is at least plausible. --no-topo-sort falls back to alphabetical if you want the legacy behaviour.
  • shortestCycle — when reporting, don't dump the whole SCC. Find the shortest actual cycle through it and print that path. A 40-file SCC printed in full is not a bug report, it's a wall.
  • cycleEdges — the edges internal to a cycle, so you can filter them to the eager ones and name the exact line that will break.

The non-obvious piece: the barrel is modelled as a node in the graph. contractBarrel and withBarrelExports insert the generated file into the graph with edges to everything it re-exports, then re-run the analysis. That's how the tool can answer "does adding this barrel create a cycle that didn't exist before" — which is the actual question, and one you cannot answer by analysing the source alone.

Stage 4 — The barrel safety model#

Running analyzeBarrel gives one of four states. This is basically the tool's worldview:

barrel safety statestext
safe        not part of any cycle                     nothing to do
type-safe   cycle only through type positions          erased at compile time; becomes real if someone drops an import type, or verbatimModuleSyntax turns on
ordered     runtime cycle, every read is deferred      works today — one eager read away from unsafe
unsafe      cycle with an init-time read               fails at runtime, not "might"

The ordered state is the one I'm most glad exists. It's the state most large Express and Nest codebases are actually in, and neither "you're fine" nor "you have a circular dependency" is a true description of it. It's a loaded gun with the safety on.

Stage 5 — Diagnostics#

Nine codes. Each one carries a severity, the files involved, the shortest cycle path, and advice that's specific to that cycle rather than generic.

diagnostic codestext
GI001  error  Cycle with an init-time read — will break at runtime
GI002  error  The barrel is inside a cycle with an init-time read
GI003  warn   Cycle exists, all reads deferred — resolves at call time
GI004  warn   Barrel inside a cycle, all reads deferred — fragile, not broken
GI005  info   Type-only cycle — erased before runtime
GI006  warn   Export name collision — two files export the same name
GI007  info   Exports withheld by --safe-barrels to keep the barrel acyclic
GI008  info   Direct or dynamic import recommended for this edge
GI009  info   NestJS forwardRef recommended — decorator-time read between framework files

Two of those deserve a note.

GI006 (collisions) — if user.service.ts and admin.service.ts both export createUser, the barrel physically cannot re-export both. First one wins, the second is silently dropped, and you spend an hour wondering why you're calling the wrong function. So it's reported, with both file paths, and the advice is: rename one, or exclude the losing file with --skip.

GI009 — when a decorator-time read appears in a cycle and the files match the NestJS naming convention (.module.ts, .service.ts, .controller.ts, .guard.ts, .resolver.ts, .interceptor.ts, .pipe.ts, .filter.ts), the fix is almost always forwardRef, so the tool says that specifically instead of giving general advice about module graphs.

For CI, --strict turns findings into exit code 1, and you can scope it:

terminalbash
npx gen-import --strict=cycles       # GI001 only
npx gen-import --strict=barrels      # GI002 / GI004
npx gen-import --strict=collisions   # GI006
npx gen-import --strict              # everything (default)

--strict-cycles still works as a deprecated alias for --strict=cycles.

Stage 6 — Emission: four different barrels#

This is where I stopped believing there's one correct output. What gets written depends on module system, language, and what the analysis found.

Static re-exports (ESM default)#

gen-import.tsts
export type { UserDto } from './user/user.dto'
export { UserService } from './user/user.service'

Clean, standard, tree-shakeable by any bundler. Also the one that can genuinely deadlock on a cycle, because ESM live bindings resolve during evaluation and there is no escape hatch.

Lazy getters (CJS default)#

For CommonJS, the barrel doesn't resolve anything until you touch it:

gen-import.jsjs
Object.defineProperty(module.exports, 'UserService', {
  get() { return require('./user/user.service').UserService },
  enumerable: true,
  configurable: true,
})

Paired with a declare line so TypeScript still knows the type:

gen-import.d.tsts
export declare const UserService: typeof import('./user/user.service').UserService

Nothing is required until first property access, so the cycle never gets a chance to bite. --lazy is on by default for CJS, --no-lazy forces static, and if your package.json says "type": "module" the tool warns and falls back to static — because you genuinely cannot do this in ESM.

The bug that cost me a night: I originally installed the getters on exports, not module.exports. Works in plain node. Returns undefined in tsx. The reason is that esbuild-based loaders (tsx, and bun does it too) reassign module.exports for any file containing export syntax — so my getters were sitting on an object nothing pointed at anymore. One word. No stack trace. That explanation is now hard-coded into the header of every generated file so I never rediscover it.

Globals mode#

terminalbash
npx gen-import --globals

Registers every value export on Node's global, plus a declare global block so the IDE still type-checks. Import the barrel once in your entry point and no other file needs an import statement at all:

src/main.tsts
// src/main.ts
import './gen-import'

// anywhere else — no import needed
const svc = new UserService()

Only values get registered, obviously. A type on global is an undefined property with a confident name.

This is the most "magic" mode and I'd only use it in an app you own end to end. It kills tree-shaking completely and makes every symbol look like it came from nowhere.

JS projects#

If there's no tsconfig.json and no .ts files, you get gen-import.js (runtime) plus gen-import.d.ts (types) as a pair, so JS projects keep IDE completion. TS projects get a single .ts file, and --no-js / generateJs controls whether a .js companion is emitted alongside.

--safe-barrels: refusing to generate the broken thing#

The most opinionated flag. If including an export would put the barrel inside a cycle, don't include it.

Two outcomes per file:

  • Demoted — the file has types, so the types are re-exported and the values are stripped. Types are erased before runtime, so they can't create a runtime cycle. You lose nothing.
  • Dropped — the file has only values, so it's excluded entirely. The tool prints the exact direct-import line to use instead, plus why it was withheld: either it reads through the barrel while its own module body runs, or it shares the barrel's cycle and withholding it is what breaks the loop.

After withholding, the barrel is re-analysed to confirm the result is actually safe or type-safe, and if it is, GI007 reports what it cost you. The summary box shows the transition, struck-through:

summary box excerpttext
Barrel        unsafe → safe
Safe barrels  on · 2 demoted, 1 dropped

I like this flag because it's the tool admitting the limits of its own approach. A generator that will happily write a file it knows will crash is not a good tool.

Everything else#

--app-config#

Generates gen-app-config.ts, an aggregator that re-exports from gen-import (and gen-package if present). The point is a single stable import surface for your app: downstream code imports only from gen-app-config and never from an individual source path or from a barrel directly.

By default it auto-updates — it rescans, diffs against the names already present, and appends only the new ones. --no-auto-update turns that off.

--map#

Export map visualisation, three formats:

terminalbash
npx gen-import --map                      # tree in the terminal
npx gen-import --map --map-format json
npx gen-import --map --map-format mermaid --map-out docs/graph.md

Console gives you a tree per file — values, types, defaults, and who imports it. JSON gives you the raw structure for other tooling. Mermaid gives you a flowchart LR you can paste straight into a README or dev.to post (labels truncate at 50 characters so the diagram stays readable).

Regardless of format, every --map run also writes docs/export-map.json, so you can commit it and diff your public surface between branches. On this repo it currently reports 15 files, 303 exports, 18 internal import edges.

--no-imports skips the import-relationship pass if you only want the export inventory and want it fast.

--watch#

Recursive fs.watch on srcDir with a 150ms debounce, filtered to .ts / .js, with a re-entrancy guard so a slow regeneration can't overlap itself, and a SIGINT handler that closes the watcher cleanly. Regenerates every barrel you asked for on every change.

Module file deferral#

Files matching .module.ts, .routes.ts, .router.ts, .route.ts are always appended last in the barrel. These files reference services and repositories, so if they're initialised before their dependencies you get a circular-require at startup — the classic NestJS and Express-router failure. -m / --module-pattern (repeatable) lets you add your own patterns.

This is a heuristic, and I'd rather it eventually be replaced by pure graph ordering. But it's a heuristic that has never once been wrong on a real project, which is more than I can say for some of my principled solutions.

--skip and --pure-reexport#

--skip <substring> excludes anything whose path contains it. --pure-reexport <path> marks a file that is already re-exported by another barrel — an index.ts you wrote by hand — so it doesn't get double-exported. Note that pureReexports paths are relative to rootDir, not srcDir, which has bitten more than one person, including me.

Config file#

gen-import.config.js (or .cjs on ESM projects) in the project root: srcDir, outFileName, moduleFilePattern, skipPatterns, pureReexports, generateJs. CLI flags always win over config values.

The console output#

Two boxes on every run: a summary (files, exports, language, module type, globals/lazy/toposort state, import edge count, cycle count split into total vs init-time, barrel safety, collisions, and a diff of newly added exports) and an import/export graph showing every file, its exports tagged [T] / [V] / [D], and the barrel they feed into.

The "new exports" diff is the feature I use most day to day. It reads the previous barrel before overwriting it and tells you exactly what appeared — a quiet, free review of what you added since the last run.

Programmatic API#

Everything the CLI does is exported: genImport, genAppConfig, genExportMap, watchSrc, and genPackage.

genPackage is deliberately not in the CLI. It reads dependencies (optionally devDependencies) from package.json and generates a gen-package.ts of export * from '<pkg>' lines. It's useful, but it has a sharp edge: packages using export = (Express is the obvious one) are fundamentally incompatible with export * from, so they have to be excluded and imported directly. That's too much footgun for a flag people will discover by reading --help, so it stays API-only until I have a better answer.

The graph utilities are exported too — buildModuleGraph, tarjanScc, topoOrder, cyclicSccs, shortestCycle, cycleEdges, condensation, analyzeBarrel, selectSafeExports, detectCycles, buildDepGraph, createTsProgram. If you want the analyser and none of the code generation, take it. That's what it's there for.

Design decisions, and what they cost#

  • One ts.Program per run. Created once, passed to both the export analyser and the graph builder. Creating two would roughly double the slowest part of the run.
  • Compiler API over regex. Slower — noticeably so on big projects, and this is the tool's main performance ceiling. Also the only way to get type-vs-value classification right, which is non-negotiable.
  • Manual process.argv parsing, no CLI parser dependency. Three runtime deps total (typescript, boxen, chalk), and I'd like to keep it that way. A dev tool that installs 40 transitive packages to print a box is not a dev tool I want to maintain.
  • The barrel is analysed as part of the graph, not separately. More code, but it's the only way to answer the question that actually matters.
  • No test script, tsc is the CI gate. That's a real gap, not a design decision, and it's the next thing I'm fixing. Being honest about it here so I actually do it.

When you should not use this#

I'd rather say this than have someone find out the hard way.

A barrel means importing one symbol evaluates everything the barrel touches. On a 400-file Express app, one import from ./gen-import initialises all 400 modules. You feel that immediately as:

  • Serverless cold starts. If you deploy to Lambda or Cloud Run, don't route production code through a full-project barrel. Measure it.
  • Test startup. A unit test that needs one pure function now boots your DB config, your Redis client, and your queue.
  • Tree-shaking. Bundlers can shake barrels, but re-export chains defeat it more often than anyone admits, and side effects in any barrel member kill it outright.

The pattern that holds up in large repos: barrels at package boundaries, direct imports inside a package. gen-import is at its best generating that boundary barrel, or being used purely as an analyser via --map and --strict.

That's also why --safe-barrels and the whole diagnostic layer exist. I'd rather ship a barrel generator that tells you when not to use a barrel than one that pretends the tradeoff isn't there.

Where it's going#

Three things, in order:

  • Types-only as the primary artifact. A .d.ts with declare global gives you the IDE experience with zero runtime edges — which means zero cycles, structurally. The physical barrel becomes opt-in rather than the default.
  • A resolver API. Right now the tool only knows your src. A resolver would let z, Router, Queue, PrismaClient resolve from your dependencies too, with shipped presets for Express, Prisma, Zod, BullMQ and node:*. This is the unplugin-vue-components idea properly applied to a backend.
  • Transform-time injection. Skip the barrel entirely where the build allows it and inject the direct import per file. Best runtime characteristics, worst configuration surface — so it comes last, and it starts with exactly one adapter.

Try it#

terminalbash
npm i -D gen-import
npx gen-import --map           # look before you generate
npx gen-import --safe-barrels  # then generate
npx gen-import --strict        # then gate it in CI

github.com/elrefai99/Gen-Import · MIT · the repo dogfoods itself, so src/gen-import.ts in there is generated output you can read.

If it breaks on your repo, open an issue. Most of what's in this post exists because it broke on someone's repo first — usually mine.

What is gen-import?
gen-import is an MIT-licensed npm CLI that generates a barrel file for a TypeScript or JavaScript project. It reads the module graph with the TypeScript compiler API, classifies every export as a type or a value, detects unsafe import cycles, and picks an emission strategy: static re-exports, lazy CommonJS getters, or globals mode.
Why can't a regex-based barrel generator tell types from values?
Because that distinction lives in the type system, not the syntax. An interface and a class can look identical as text, but only one exists at runtime. Re-exporting a type as a value under isolatedModules or verbatimModuleSyntax produces a real import of something that no longer exists after compilation, which throws at import time.
Is every circular import a bug?
No. A cycle only breaks when something reads a binding while a module body is still executing — a class heritage clause, a decorator argument, a static field. If every reference inside the cycle is deferred into a function body, both modules finish initialising fine and the cycle never gets a chance to matter.
What does the --safe-barrels flag do?
It refuses to include an export in the generated barrel if doing so would put the barrel inside an unsafe cycle. Files that export types get demoted to type-only re-exports; files with only values get dropped entirely, with the direct-import line printed as a replacement. The barrel is then re-analysed to confirm the result is actually safe.
When should you not use a barrel file?
When cold-start time or test isolation matters. Importing one symbol from a barrel evaluates every module it touches, so a single import in a Lambda handler or a unit test can boot an entire application's worth of unrelated modules. The pattern that scales is barrels at package boundaries, direct imports inside a package.
Why do the generated CommonJS getters use module.exports instead of exports?
Because esbuild-based loaders such as tsx and bun reassign module.exports for any file containing export syntax. Getters installed on the original exports object end up attached to an object nothing points at anymore, so property access silently returns undefined instead of throwing.