iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📌

The packageDirectory Option of eslint-plugin-import-access

に公開

Hello everyone. Today, I'd like to introduce the new packageDirectory option added in the latest version (v3.1.0) of the ESLint plugin eslint-plugin-import-access, which I maintain.

This option significantly expands the possibilities of how the plugin can be used. Please give it a try.

Review: What is eslint-plugin-import-access?

First, for those who are not familiar with this plugin, let me explain the basics. The image below illustrates the core functionality of eslint-plugin-import-access concisely.

Image explaining eslint-plugin-import-access functionality (explained in the text below)

Without eslint-plugin-import-access, you can freely import from anywhere to anywhere within a project. However, in reality, there are many cases where you want to restrict imports. For example, when using a package-by-feature directory structure, you may want to avoid freely calling internal functions of one feature from another.

By using eslint-plugin-import-access in such cases, you can place restrictions on imports.

In the image above, const foo is exported from sub/foo.ts. Importing this foo from sub/bar.ts is allowed, but importing it from main.ts outside sub results in an error.

In other words, in this example, the sub directory is treated as a "package"; imports within the package are free, but importing from outside the package (main.ts) into the package is not allowed.

This is the basic function of eslint-plugin-import-access: adopting the concept of "packages" to restrict arbitrary imports.

There are other ESLint rules that restrict arbitrary imports, but a major difference with eslint-plugin-import-access is control via JSDoc.

In the image example above, if you look closely, you'll see that @package is specified in JSDoc as shown below:

/**
 * @package
 */
export const foo = 3;

By specifying @package like this, it means that this export is "package-private". Package-private means "should not be imported from outside the package."

In other words, eslint-plugin-import-access does not require you to specify all rules in the configuration file; instead, by using JSDoc, you can specify for each export whether it can be imported from outside the package.

By default, only those marked with @package are subject to restriction by eslint-plugin-import-access. Items not marked with @package can be freely imported from outside.

However, by specifying the defaultImportability: "package" option, you can make the default behavior @package. In this case, without specifying it in JSDoc every time, all exports become import-restricted from outside the package. To make an export importable from outside the package as an exception, you specify @public in JSDoc.

defaultImportability: "package" is used when you want to control the entire project with eslint-plugin-import-access.

New Option packageDirectory

So far, I've explained the concepts of "package" and "outside package," but what exactly is a package?

In fact, up until now, in eslint-plugin-import-access, a package is a directory, and all directories were treated as packages by eslint-plugin-import-access.

That is, a file treated as @package cannot be imported from outside the directory where that file resides.

While this is a simple rule, it was inflexible and inconvenient. It simply made it impossible to import anything inside a directory from outside. There is an indexLoophole option that allows index.ts to be imported from the parent directory, so in practice, using index.ts to re-export items from within the directory became essential, resulting in many unnecessary index.ts files.

To improve this situation, the new packageDirectory option has been introduced. This option uses glob patterns to specify which directories should be treated as packages. You can specify it as follows:

// Default (all directories are packages)
packageDirectory: ["**"]
// Directories named _internal are not treated as packages
packageDirectory: ["**", "!**/_internal"]
// Directories directly under src/packages become packages
packageDirectory: ["src/packages/*"]

For example, suppose you set directories named _internal to not be treated as packages (this use case was provided by @honey32).

In this case, the behavior would be as follows:

// ----- src/feature1/_internal/helpers.ts
/**
 * @package
 */
export const internalHelper = () => { /* ... */ };

// ----- src/feature1/foo.ts
import { internalHelper } from "./_internal/helpers"; // ✓ Can be imported

// ----- src/main.ts
import { internalHelper } from "./feature1/_internal/helpers"; // ✖ Cannot be imported

First, internalHelper is exported with @package, but the package it belongs to is src/feature1. Its immediate parent directory is src/feature1/_internal, but since that directory is not treated as a package, the parent further above becomes the owning package.

This allows importing internalHelper from src/feature1/foo.ts. This file also belongs to src/feature1, so it's an import within the same package.

On the other hand, importing from src/main.ts is not possible. This is because main.ts is outside the src/feature1 package, and importing from outside into the package is forbidden.

In this way, packageDirectory increases the flexibility of organizing files within a package.

I believe that with the advent of packageDirectory, the practicality of defaultImportability: "package", mentioned above, has improved. Previously, this option was too strict and difficult to use, but now that you can configure which units are treated as "packages," you can maintain order in your project without imposing excessive restrictions.

Use packageDirectory to divide your code into appropriately sized packages, and use defaultImportability: "package" to restrict imports between packages.

Then, add @public only to exports that are intentionally intended to be provided to other packages.

This clarifies the interface of each package (what functionality it provides to the outside).

For example, in a frontend application where packages are organized by screen, you might only make the top-level screen component @public:

// src/features/foo

/**
 * @public
 */
export const FooPage: React.FC = ...

Regarding "Meta Settings"

packageDirectory is convenient as described, but there is a trick to writing its configuration. That trick is: instead of listing individual directories, describe a "rule for directory names" in packageDirectory. More simply put, use wildcards (* and **).

For example, suppose you adopt package-by-feature and want to treat src/packages/foo and src/packages/bar as packages. In that case, avoid explicitly listing foo and bar in the configuration file; use * instead.

// Configuration to avoid
packageDirectory: ["src/packages/foo", "src/packages/bar"],
// Preferred configuration
packageDirectory: ["src/packages/*"],

The former approach is problematic because you need to modify the ESLint configuration file every time a package is added. By setting a higher-level rule, you can avoid this situation.

It might also be interesting to try configurations like the following:

// Directories named foo.package are treated as packages
packageDirectory: ["**/*.package"],
// Directories named bar.internal are not treated as packages
packageDirectory: ["**", "!**/*.internal"],

With configurations like the above, once the rules are defined, whether a directory is a package is determined by its naming convention. In other words, the person creating the directory can also decide the rules for that directory.

In this approach, the ESLint configuration (packageDirectory) essentially becomes a "rule for setting rules." I call this a meta configuration. It's similar to how Next.js's framework has file/directory naming conventions. By using eslint-plugin-import-access, you can define your own naming conventions.

Summary

The addition of the new packageDirectory option to eslint-plugin-import-access has increased its convenience and expanded its range of applications.

In particular, this option makes operating with defaultImportability: "package" a realistic proposition. Define rules for packageDirectory according to your project's situation and structure. This allows you to efficiently detect undesired imports.

Moreover, the concept of "meta configuration" further advances the characteristic that eslint-plugin-import-access originally had: "using JSDoc to specify rules at the source rather than in the configuration file." In addition to JSDoc, directory names have now been added as a new tool.

By the way, the implementation of this option was entirely done by Claude Code, leveraging some credits provided on the web. I think it should be fine, but if you encounter any issues while using it, please do share them via issues or other means.

GitHubで編集を提案

Discussion