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

Node.js 12 and Webpack 5 added support for exports maps to address these scenarios. Exports maps are defined under the exports property in package.json, and they allow libraries to be explicit about what entry points are allowed. This can help with keeping the API surface explicit and minimal, as well as providing intellisense for allowed paths. For example:

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

Usage:

js
import { Thing } from 'some-pkg'
import { Button } from 'some-pkg/Button'

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. So import { privateUtil } from 'some-pkg/lib/top/secret/internals' is now an error.

Multiple entry points

As shown above, it's possible to add subpath exports if a package needs to allow multiple entry points.

jsonc
{
  "exports": {
    // Main entry point
    ".": "./lib/index.js",
    // Button only
    "./Button": "./lib/components/Button/index.js",
    // Alternative entry point for test utilities
    "./test": "./lib/test/index.js",
  },
}

Multiple module formats

To support multiple module formats, we use conditions. When a path is being resolved using require, the "require" condition will be used. When being imported in an ES module, "import" will be used. Now we can support both ESM and CJS (and optionally corresponding types) without the path referring to the module flavor:

json
{
  "exports": {
    ".": {
      "require": "./lib-commonjs/index.js",
      "import": "./lib-esm/index.js"
    },
    "./Button": {
      "require": "./lib-commonjs/components/Button/index.js",
      "import": "./lib-esm/components/Button/index.js"
    }
  }
}

There are many benefits to this:

  • No leaky abstractions
  • Smaller import paths (/Button rather than lib/components/Button.)
  • Works with CJS or ESM without special remapping

We can take this a step further by providing a "source" condition for development mode, which helps to translate the above mappings into source files (.ts/.tsx) and reduces complexity in resolving modules in a local dev environment. You can also add "types" so TS can resolve types for custom import paths if the source isn't published.

jsonc
{
  "exports": {
    ".": {
      "source": "./src/index.ts",
      "types": "./lib/index.d.ts",
      // "default" is a generic fallback condition.
      // Alternatively (or in addition) you could use "import" or "require"
      // as appropriate.
      "default": "./lib/index.js",
    },
  },
}

In a Webpack config, the source condition can be added to resolve.conditionNames to auto-resolve source files.

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