📂

eslint-plugin-import-accessではじめるディレクトリ単位カプセル化

2021/06/27に公開

こんにちは。この記事は筆者が製作した ESLint 向けプラグイン eslint-plugin-import-accessを紹介する記事です。

このプラグインにより TypeScript プログラムに擬似的なpackage-private exportの概念が生まれます。JSDoc で@packageとアノテートされたexport宣言は、そのファイルが属するディレクトリの外からインポートすることができなくなります。

従来、TypeScript で可能なカプセル化の最大の単位は「ファイル」であり、ファイルからエクスポートしない変数はそのファイル(モジュール)の中に閉じている一方で、一旦エクスポートしたものはプロジェクトのどこからでもインポート可能になります。これでは不都合な場合がありました。

最近の具体的な例としてはRecoilが挙げられます。筆者の以前の記事では、Atom や Selector といったものをエクスポートするのではなくそれらをラップしたカスタムフックをエクスポートするのが良いと述べました。しかし、Selector のネットワークを張り巡らせようとすると、Selector をエクスポートできないためひとつのファイルが肥大化してしまいます。

このような問題を解決するために、eslint-plugin-import-accessは「ディレクトリ」というより大きな単位のカプセル化を提供します。

これはTypeScript 本体の機能として欲しいという issueを上げており 70 以上の 👍 を得ていましたが TypeScript チームに動きはなく、仕方ないので ESLint プラグインとして作ったものです。

具体例

次のように src/sub/foo.ts からエクスポートされた変数たちを考えてみましょう。普通のexportは public(どこからもインポートできる)な従来通りのexportです。@packageは package-private になります。また、おまけとして@privateも実装しました。これはどこからもインポートできないという意味です[1]

src/sub/foo.ts
export const subPublic = "Hi, I'm public"

/**
 * @package
 */
export const subPackage = "package private"

/**
 * @private
 */
export const subPrivate = "HIDDEN"

これらを同じsubディレクトリ内のindex.tsからインポートする場合、public エクスポートも package エクスポートもインポート可能です。private エクスポートは ESLint によりエラーが発生します。

src/sub/index.ts
import { subPublic, subPackage, subPrivate } from "./foo";
//                              ^^^^^^^^^^ ここにエラー発生
console.log(subPublic, subPackage, subPrivate);

VSCodeのスクリーンショット

一方で、subディレクトリの外のファイルからインポートする場合、subの中の package エクスポートをインポートすることはできなくなります。

src/
import { subPublic, subPackage, subPrivate } from "./sub/foo";
//                  ^^^^^^^^^^  ^^^^^^^^^^ ここにエラー発生
console.log(subPublic, subPackage, subPrivate);

VSCodeのスクリーンショット

このように、ファイルの位置関係によって package エクスポートをインポートできるか否かが決まります。

TypeScript のディレクトリ構成が意味を持つ

これまで、TypeScript プロジェクトのディレクトリ構成というのは、(フレームワークによって決められたものを除けば)我々の気分で好きに決めていました。従来はどこでexportされた変数をどこからimportすることもできるのであり、ディレクトリ構成の意味はプロジェクトの見通しをよくするといったあくまで管理上のもので、プログラムそのものとは無関係でした。

この記事の冒頭でも述べたように、eslint-plugin-import-accessによってディレクトリ構成に「カプセル化」という実利上の意味が与えられることになります。これにより、今後の TypeScript プロジェクトでは@packageの利用を念頭に置いたディレクトリ構成が考えられることになります。とても楽しみですね。

TypeScript Language Service Plugin

勘のいい読者の方は、ESLint だけでは開発体験が十分でないことにお気づきでしょう。良いエディタを使って TypeScript を書いている方にとっては、import文は自分で書くものではなく自動的に補完されるものです。

一方、ESLint はあくまで記述されたコードの問題点を検出するものですから、勝手に package-private なエクスポートがimportされて怒られることが想定されます。これは開発体験がよくありません。

そこで、eslint-plugin-import-accessにはこの点を補う TypeScript Server Plugin が同梱されています。これを有効にすることで、@packageまたは@privateが付いているためインポートできないものは補完から除外されます。

VSCode を使っている場合の動作例を示します。上の例のsrc/index.tssubPまで入力すると、subPublicのみが自動インポート候補として表示されます。

src/index.tsのスクリーンショット

一方、src/sub/index.tsで同様にsubPまで入力すると、subPublicsubPackageが候補に表示されます。この位置では同じディレクトリ内のsrc/sub/foo.tsからエクスポートされたsubPackageをインポートすることができるので、それが補完候補にもきちんと現れています。

src/sub/index.tsのスクリーンショット

このように、TypeScript Language Service Plugin を使うことで開発体験を損なわずにeslint-plugin-import-accessの恩恵を受けることができます。

2 種類の抜け穴

実は、eslint-plugin-import-accessには 2 種類の抜け穴 (loophole) が用意されています。これらはオプションで有効にするかどうかを決めることができ、デフォルトではindex.tsのほうのみが有効になっています。

index.tsによる抜け穴

TypeScript では、import { ... } from "./sub"のようにすることでsub/index.tsからインポートすることができます[2]。これに対応してindex.tsを特別扱いするという抜け穴がデフォルトで有効になっています。

例えば、sub/index.ts@packageエクスポートされたものは、subディレクトリの隣にあるファイルからも読み込むことができるようになります。これは、sub内の package-private な機能たちを再エクスポートしたいときに便利です。次の例では、sub/index.tsfoo@packageで再エクスポートします。こうすると、subの親ディレクトリからもfooが利用できるようになります。

src/sub/foo.ts
/**
 * @package
 */
export const foo = "FOO";
/**
 * @package
 */
export const bar = "BAR";
src/sub/index.ts
import { foo } from "./foo";

/**
 * fooを再エクスポート
 * @package
 */
export { foo };
src/user.ts
// 読み込める!
import { foo } from "./sub";

これにより、ディレクトリ単位の“パッケージ”がネストするような場合には、index.tsをサブパッケージの窓口とする運用が可能です。

ファイル名による抜け穴

以上のような抜け穴は再エクスポートを可能にするために用意されたものですが、index.tsにそのような特別な役割を持たせるべきではないという意見がもしかしたらあるかもしれません。

というのも、Node.js でも ECMAScript Modules がサポートされていますが、こちらにはindex.jsを特別扱いするような機構はないのです。./subではなく./sub/index.jsとしないとindex.jsを読み込めません。

そのような状況で、index.tsに新たな特別性を持たせるのは望ましくないという意見があるかもしれません。そこで、もう一つ用意された抜け穴がファイル名による抜け穴です。こちらはデフォルトでは有効になっていないので、使用する場合はオプションで有効にする必要があります。

こちらの抜け穴は、ディレクトリ名と同じファイルからはディレクトリの中の package エクスポートをインポートできるというものです。例えば、sub.tssub/直下のファイルの package エクスポートをインポートできます。つまり、sub/index.tsではなくsubディレクトリと同じ位置に並べたsub.tssub/以下の窓口にしようという考え方です。

src/sub/foo.ts
/**
 * @package
 */
export const foo = "FOO";
src/sub.ts
// 読み込める!
import { foo } from "./sub/foo";

このようにディレクトリと同名のファイルを窓口にするのがどれくらいメジャーなのかは寡聞にして分かりませんが、Rust(2018 エディション)で採用されているのを目にしたので取り入れました。

まとめ

この記事では、TypeScript プロジェクトにディレクトリレベルの package-private エクスポートという新しい概念をもたらすために作られた、ESLint ルールおよび TypeScript Language Service Plugin であるeslint-plugin-import-accessを宣伝しました。

https://github.com/uhyo/eslint-plugin-import-access

現在は1.0.0-betaが公開されています。すこしドッグフーディングしてみて正式リリースに持っていきたいなと思っています。いいなと思った方はぜひ使ってみてください。リポジトリへの ⭐️ もぜひお願いします。

補足

実は、ESLint で import の可不可を制御できるものはeslint-plugin-importなど既存のプラグインがあります。今回作ったeslint-plugin-import-accessがそれらと一線を画しているのは、export宣言に直接@packageというアノテーションを書くことでexport単位で制御できることです。既存のソリューションでは設定ファイルで制御することになり、プロジェクトの大ざっぱな区分けをすることはできますが小さなディレクトリ単位までカプセル化の恩恵を受けるのには適していません。eslint-plugin-import-accessにより、気軽に package-private export が使えるようになったのです。

脚注
  1. それならexportする意味がないように思えますが、一応 ignore comment を書いた場合だけインポートできるので何か使い道があるかもしれません。 ↩︎

  2. 正確には moduleResolutionコンパイラオプションを"node"にしている場合。このことから分かるように、TypeScript 特有ではなく Node.js の挙動に由来するものです(CommonJS の場合)。 ↩︎

GitHubで編集を提案

Discussion