Try   HackMD

Why are deep imports bad?

Many apps use deeply nested file path package imports across their packages:

import { canary } from 'owa-service/lib/foo/bar/canary';

This as a bad practice - pulling arbitrary source files from the lib folder means that:

  1. The package must be built first for compilation to work, or the path must be programatically remapable.
  2. The consumed package api surface becomes larger than may have been anticipated by the author. The fact that you CAN import any arbitrary source file means that every file becomes a contract; you can not rename, move, or change the exports of that file without potentially breaking something which depends on that. Even changing the case of a filename will break the contract.
  3. The lib folder name itself is an abstraction leak as it implicitly refers to a module format. Its contents have been a subject of debate in the past; does it contain commonjs code? es modules? or {some future format}? When a developer wants to import a subtree of a package, it should be as simple as possible; e.g. import Button from "package/Button" rather than from "package/lib/components/Button/index". And, intellisense should be available to help the dev know what's allowed to be imported.

Node package.json format has recently addressed these scenarios with the added exports property. This property allows libraries to be explicit about what entry points are allowed, which can help with intellisense and with keeping the api surface explicit and minimal:

{
  exports: {
    ".": "./lib/index.js",
    "./Button": "./lib/components/Button/index.js"    
  }
}

To support multiple module formats, we use standard "conditions" which will be used to resolve paths. When a path is being resolved using require, the require condition will be used. And when being imported, import will be used. Now we can support both esm and cjs without the path referring to the module flavor:

{
  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"
    
  }
}

So many benefits to this:

  1. No leaky abstractions
  2. Smaller import paths (/Button rather than lib/components/Button.)
  3. Works with cjs or esm without special remapping
  4. Contracts are explicit, meaning you can't import lib/top/secret/internals without them being added to the map.

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 (tsx/ts), future reducing complexity in resolving modules in a dev environment:

{
  exports: {
    ".": {
      "source": "./src/index.tsx",
      "default": "./lib/index.js"
    }
  }
}

The source condition can be added to conditionNames in webpack config to auto resolve source files.

References

Exports details in package.json config:
https://nodejs.org/api/packages.html#packages_subpath_exports

Webpack config details on adding resolve conditions:
https://webpack.js.org/configuration/resolve/#resolveconditionnames