Skip to content

Repo guidance: Minimize deep imports

The term "deep import" or "subpath import" refers to any import which is not from the package root:

js
import { Button } from 'some-pkg/lib/Button'
import { privateUtil } from 'some-pkg/lib/top/secret/internals'

Background

Deep imports may be necessary in some cases, but they have some pitfalls and are commonly outright misused/overused.

Pattern: Alternative entry points

Some packages (which don't have exports maps) may intentionally provide a set of alternative entry points:

js
// Individual component import (from a large package) to save parse time
import { Button } from 'some-pkg/lib/Button'
// Test utility or mock which is intended for consumption but not part of the
// package's primary API surface
import { someTestUtil } from 'some-pkg/lib/test'

This makes sense in some cases, but it still has issues:

  • The package must be built first for compilation to work, or the path must be programmatically remappable.
  • The output folder name (such as lib) itself is an abstraction leak as it may implicitly refer to a module format.
    • The contents of lib have been a subject of debate: does it contain CommonJS code? ES modules? original source? or some future format?
    • Supporting multiple output formats requires path remapping.

When a developer wants to import a subtree of a package, ideally it should be as simple as possible; e.g. import { Button } from "some-pkg/Button" rather than "some-pkg/lib/components/Button/index" or "some-pkg/lib/Button". Also, intellisense should be available to help the dev know what's allowed to be imported.

Arbitrary deep import paths

Many apps use deep imports of arbitrary file paths from other packages:

js
import { privateUtil } from 'some-pkg/lib/top/secret/internals'

This is a bad practice. In addition to the issues outlined above, pulling arbitrary source files from deep paths means that the consumed package API surface becomes larger than may have been anticipated by the author. The fact that consumers can import any arbitrary source file means that every file becomes a contract; you cannot rename, move, or change the exports of that file without potentially breaking something which depends on it.

Alternative: Exports maps

Exports maps provide a better way to address these scenarios. They're defined under the exports property in package.json and allow libraries to be explicit about what entry points are allowed, as well as supporting multiple module formats. See the link above for more details, but a brief example:

json
{
  "name": "some-pkg",
  "exports": {
    ".": "./lib/index.js",
    "./Button": "./lib/components/Button/index.js"
  }
}

Once a package defines an exports map, Node, Webpack 5+, and most other tools will refuse to resolve imports of other paths within the package.

js
// these match exports map paths
import { Thing } from 'some-pkg'
import { Button } from 'some-pkg/Button'
// ❌ no longer allowed
import { privateUtil } from 'some-pkg/lib/top/secret/internals'

Enforcement

The recommended config from @ms-cloudpack/eslint-plugin enables a rule @ms-cloudpack/no-unsupported-imports, which bans deep imports from paths that aren't defined in a package's exports map. It also bans all deep imports from packages which don't define exports maps.

In some cases, the rule will provide a "quick fix" suggestion of importing from the package root, but it can't currently do automatic fixes. Correct automatic fixes would require either type information (to figure out where symbols are defined and whether they're exported from the root) or complete manual traversal of a package's exported paths and symbols.

References