eslint-plugin-import-accessではじめるディレクトリ単位カプセル化
こんにちは。この記事は筆者が製作した 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]。
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 によりエラーが発生します。
import { subPublic, subPackage, subPrivate } from "./foo";
// ^^^^^^^^^^ ここにエラー発生
console.log(subPublic, subPackage, subPrivate);
一方で、sub
ディレクトリの外のファイルからインポートする場合、sub
の中の package エクスポートをインポートすることはできなくなります。
import { subPublic, subPackage, subPrivate } from "./sub/foo";
// ^^^^^^^^^^ ^^^^^^^^^^ ここにエラー発生
console.log(subPublic, subPackage, subPrivate);
このように、ファイルの位置関係によって 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.ts
でsubP
まで入力すると、subPublic
のみが自動インポート候補として表示されます。
一方、src/sub/index.ts
で同様にsubP
まで入力すると、subPublic
とsubPackage
が候補に表示されます。この位置では同じディレクトリ内のsrc/sub/foo.ts
からエクスポートされたsubPackage
をインポートすることができるので、それが補完候補にもきちんと現れています。
このように、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.ts
はfoo
を@package
で再エクスポートします。こうすると、sub
の親ディレクトリからもfoo
が利用できるようになります。
/**
* @package
*/
export const foo = "FOO";
/**
* @package
*/
export const bar = "BAR";
import { foo } from "./foo";
/**
* fooを再エクスポート
* @package
*/
export { foo };
// 読み込める!
import { foo } from "./sub";
これにより、ディレクトリ単位の“パッケージ”がネストするような場合には、index.ts
をサブパッケージの窓口とする運用が可能です。
ファイル名による抜け穴
以上のような抜け穴は再エクスポートを可能にするために用意されたものですが、index.ts
にそのような特別な役割を持たせるべきではないという意見がもしかしたらあるかもしれません。
というのも、Node.js でも ECMAScript Modules がサポートされていますが、こちらにはindex.js
を特別扱いするような機構はないのです。./sub
ではなく./sub/index.js
としないとindex.js
を読み込めません。
そのような状況で、index.ts
に新たな特別性を持たせるのは望ましくないという意見があるかもしれません。そこで、もう一つ用意された抜け穴がファイル名による抜け穴です。こちらはデフォルトでは有効になっていないので、使用する場合はオプションで有効にする必要があります。
こちらの抜け穴は、ディレクトリ名と同じファイルからはディレクトリの中の package エクスポートをインポートできるというものです。例えば、sub.ts
はsub/
直下のファイルの package エクスポートをインポートできます。つまり、sub/index.ts
ではなくsub
ディレクトリと同じ位置に並べたsub.ts
をsub/
以下の窓口にしようという考え方です。
/**
* @package
*/
export const foo = "FOO";
// 読み込める!
import { foo } from "./sub/foo";
このようにディレクトリと同名のファイルを窓口にするのがどれくらいメジャーなのかは寡聞にして分かりませんが、Rust(2018 エディション)で採用されているのを目にしたので取り入れました。
まとめ
この記事では、TypeScript プロジェクトにディレクトリレベルの package-private エクスポートという新しい概念をもたらすために作られた、ESLint ルールおよび TypeScript Language Service Plugin である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 が使えるようになったのです。
Discussion
brand用のsymbolを定義するとどうしてもexportする必要があるので、そのときに
@private
は便利かもしれません。ところで、star import/export (
import * from
) はどうしても貫通してしまうようですね…。