Avoiding JS Import Hell

Solomon Hawk, Senior Developer

Article Categories: #Code, #Back-end Engineering

Posted on

Unwieldy modules can hinder maintainability. These 6 techniques help us prevent import statements from getting out of hand.

On large JavaScript or TypeScript codebases, imports can accumulate and, over time, become unwieldy. It isn’t unheard of to find a module with 15 or 20+ lines of import statements at the top of the file. These lines must appear before the contents of the module, occupying a prominent place within the file, but the information they contain is often secondary to the rest of the module. Imports, if not maintained well, can hinder maintainability and reduce the signal-to-noise ratio of a codebase.

There are a handful of things we do to keep them under control:

1. Absolute imports #

Relative paths can cause additional horizontal space within import lines (depending on the relation and how deeply nested your modules are). Using absolute imports can reduce this tax and also comes with other benefits (they’re easier to read and understand at a glance and allow for moving files around more easily).

Enabling them in a TypeScript codebase is as simple as setting a baseUrl and adding paths to your TS config (NextJS has a nice concise guide). If you’re using webpack, resolve.alias accomplishes the same thing.

One of the downsides to this is that other tooling that operates on your source modules must also be configured to resolve absolute imports correctly.

If you’re using vite with TypeScript, you may also want to configure it to respect TS config path mappings.

Before:

   import { Link } from '../../../components/link'
   import { Button } from '../../../components/button'
   import { Card } from '../../../components/card'
   import { useData } from '../../../hooks/data'

After:

   export { Link } from '@/components/link'
   export { Button } from '@/components/button'
   export { Card } from '@/components/card'
   import { useData } from '@/hooks/data'

2. Refactoring #

Too many imports might be an indication that your modules could benefit from being reorganized. If your module has several components defined in it, or one large, complex component that’s doing too many things, breaking it into separate components (in separate files/modules) might lead to more comprehensible and maintainable code. This also shortens the number of dependencies of each module.

3. Barrel files #

This code pattern involves grouping related modules and re-exporting them together from a single file – often an index.ts file, which acts as the entry point or public API for a set of related modules.

Before:

   // pages/dashboard.tsx
   import { Link } from '@/components/link'
   import { Button } from '@/components/button'
   import { Card } from '@/components/card'
   
   // ... dashboard component ...

After:

   // components/index.ts
   export { Link } from './link'
   export { Button } from './button'
   export { Card } from './card'
   
   // pages/dashboard.tsx
   import { Link, Button, Card } from '@/components'
   
   // ... dashboard component ...

This can dramatically reduce the number of lines of imports appearing in your files, but comes with the added cost of maintaining the barrel files themselves. I generally find it to be convenient, but others may reasonably decide that it isn’t for them.

If you do choose to use barrel files, be careful not to accidentally circumvent the barrel and import from a submodule directly. One of the benefits of this pattern is establishing a strong boundary between modules that grants flexibility in the organization of submodules while allowing consumers to remain agnostic about those details. When inconsistencies creep in, it violates this boundary and compromises the flexibility gained.

If you want enforcement of those strong boundaries, you probably need to reach for npm workspaces or some other monorepo structure that can enable truly private modules. This may come with added performance penalties and tooling/deployment/packaging complexities.

4. Linter config #

One thing that can make managing large numbers of imports easier is organizing them consistently. A predictable order can reduce the number of lines you need to visually scan to find the line you’re looking for.

ESLint can be configured to enforce this consistency through eslint-plugin-import. It’s easy to set up, has good defaults (if your ESLint config extends plugin:import/recommended), and is straightforward to customize for your specific ordering preferences.

If you want something more opinionated with fewer options, you may opt to use eslint-plugin-simple-import-sort which can be configured alongside eslint-plugin-import, so long as you don’t use the import/order rule.

Though I tend to rely on the linter to handle this, it’s worth noting that your editor may also have functions for managing import order. VS Code has two built-in commands that can be useful here: source.sortImports and source.organizeImports. The latter will sort and also remove unused imports which is quite handy when refactoring.

The benefit of relying on the linter is that it should be consistent across contributors regardless of which editors they use.

5. Editor config #

Editors like VS Code can be configured (editor.foldingImportsByDefault)  to code fold imports by default. I don’t personally use this option, but it can be useful if you’re dealing with unwieldy modules and you aren’t looking at or editing imports frequently.

With this setting enabled, instead of:

You would just see:

6. Dependency Injection #

This pattern is a form of Inversion of Control that can be useful for decoupling code from services or interfaces it depends on. Among other benefits, it tends to make testing easier by allowing us to statically provide test doubles instead of mocking modules. It also moves some dependencies from static imports to props or context, reducing the number of lines of import statements.

This technique should not be used solely to remove import lines. In fact, moving some dependencies from static to injected adds complexity to a module by separating dependencies into two groups (those statically imported, and those dynamically injected) which can hinder comprehension.

Since the overall number of lines in a module is unlikely to be reduced, you’ll want to be cautious to apply this pattern only when there are other benefits to gain, like testability.


Hopefully this list gives you some ideas about how to tame your modules and avoid Import Hell. Managing imports is just one facet of the larger concern of code quality and maintainability.

Solomon Hawk

Solomon is a developer in our Durham, NC, office. He focuses on JavaScript development for clients such as ID.me. He loves distilling complex challenges into simple, elegant solutions.

More articles by Solomon

Related Articles