named exportは有害だと考えられます
TypeScriptの話です。default exportを使うことが有害であるかのような言説に異議を唱えるためにこの記事を書きました。
あらかじめ断っておきますが、この記事はTypeScriptを使っているプロジェクトのモジュール構成に関する話です。npmに上げられているようなNode.jsパッケージ間でのimport/exportはまた別のエコシステムが関わってくる話なので、分けて考えてください。Denoにおけるimport/exportに関しては、この記事での議論がそのまま通用します。
基本的にdefault exportのみを使うべき
筆者の考えでは、named exportの方が、あなたのプロジェクトに対する害が大きいです。むしろ、「基本的にdefault exportのみを使う」ことを考えた方が良いと思います。それは以下のような理由からです。
named exportを積極的に使うことを許してしまうと、そのファイルの「目的」とは何の関係もない(本来そのファイルに置くべきでない)かもしれない定義をそのファイルからexportすることを積極的に許してしまうことに繋がるからです。そのようなモジュール構成は論理的な整合性を損ない、プロジェクトの保守性を簡単に破壊します。せっかくフロントエンド周辺技術が「ファイル単位でのモジュール化」という前提で設計を推し進めているというのに、これでは形なしです。
端的に換言すれば、「default exportを避けることは、〝ファイル=モジュール〟という発想と本質的に相入れない」ということです。そういう考え方を採用しないつもりならdefault exportを避けることにしていいと思います。
もちろんこういった話は、結局のところ開発者がどれだけ注意深くモジュール構成を設計できるかということにかかっているのであり、named exportを実際に使うことがすぐさまプロジェクトの保守性を損なうことになると言っているのではありません。「named exportを躊躇いなくふんだんに使ってよい」というような気持ちでいることが問題なのです。
すべてをnamed exportにした場合でも適切に設計すれば問題ないかもしれませんが、最悪なケースでは、見通しの観点から言って、プロジェクト全体が単一のモジュールで構成されているのとさほど変わらないような状況に陥ってしまいます。つまり、ファイルという単位に本来与えられていたはずのモジュールという意味が失われていってしまい、どの定義がどこに入っているのかがめちゃくちゃになってしまいます[1]。もはや粒度とか以前の問題です。
default exportのデメリットへの反論
コミュニティー主導で作られたとされるTypeScript Deep Diveには、「Avoid Export Default」と題されたエントリがあります。これは、TypeScriptプログラミングにおいてdefault exportがいかに有害とされているかを述べたものです。見出しを抜き出しておくと、以下のとおりです。
- CommonJSとの相互運用
- 低い検出性(Poor Discoverability)
- オートコンプリート(Autocomplete)
- タイポに対する防御(Typo Protection)
- TypeScriptの自動インポート
- 再エクスポート(Re-exporting)
- Dynamic Imports
- 非クラス/非関数の場合、2行必要です
これらの問題点に一つ一つ反論していくことで、読者がdefault exportの価値を考え直すきっかけを提供できればと思います。実際にはこれらのほとんどは中身のない話であり、「export default
は有害だと考えられます」といういささか扇情的な見出しだけが一人歩きしている状況であるということが、噛み砕いてみればよくわかるはずです。
CommonJSとの相互運用
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#commonjstono
default
は、const {Foo} = require('module/foo')
の代わりに、const {default} = require('module/foo');
を書かないといけないCommonJSユーザーにとって、恐ろしい体験になります。あなたはたいていdefault
エクスポートをインポートしたときに他の何かにリネームすることになるでしょう。
これはトランスパイラの問題であって、default exportを避ける(すなわち、ファイル単位でのモジュール化という理念を壊す)べき本質的な理由にはなりえません。default exportでも問題なく相互運用できるようにしたいなら、そのようにトランスパイラを構成すべきです。
ちなみに現在では、リネームしたいならconst { default: Foo } = ...
と書けます。const { Foo } = ...
と書くのとそんなに変わらないでしょう。
低い検出性(Poor Discoverability)
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#ipoor-discoverability
デフォルトエクスポートは検出性(Discoverability)が低いです。あなたはインテリセンスでモジュールを辿り、それがデフォルトエクスポートを持っているかどうかを知ることができません。
デフォルトエクスポートでは、あなたは何も得られません(それはデフォルトエクスポートを持っているかもしれませんし、持っていないかもしれません
¯\_(ツ)_/¯
):import /* here */ from 'something';
デフォルトエクスポートが無ければ、素晴らしいインテリセンスが得られます。
import { /* here */ } from 'something';
これはそもそもどういう問題意識なのか筆者はよく掴めていません。原語版でこの項目を追加した人自身、本当にこれで伝わると考えていたのでしょうか。みなさんはこれ理解できますか?
現代的なエディタでTypeScriptを使っているのなら、default exportの存在しないモジュールファイルをdefault importしようとしたらちゃんと認識して怒ってくれるはずです。
おそらくこれに関しては、二つ次の「タイポに対する防御(Typo Protection)」セクションと同じ解決策になりますので、そちらを参照してください。
オートコンプリート(Autocomplete)
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#tokonpurtoautocomplete
エクスポートについて知っているか、いないかに関わらず、あなたはカーソル位置で
import {/*here*/} from "./foo";
をオートコンプリートできます。それはデベロッパーに少しの安心感を与えます。
この点も何が言いたいのか明らかではありませんが、おそらくimport {} from "./foo"
と書き切ったあとで{}
内にカーソルを移動し適当にタイプしたときのことを言っているのでしょうか。もしそうなら、それはそもそもnamed exportをむやみに使いまくることによって生じる弊害です。あるモジュールに何が入っているかわからないことが問題なのです。自分(named exportを濫用すること)で生み出した問題に自分で対処できないからって、default exportのせいにしないでもらいたいものです。
基本的にモジュールというのは、importする側にとってはブラックボックスだと思っておいた方がいいです[2]。少なくとも理想論としては、そういうつもりで引数に名前をつけ、ドキュメンテーションコメントを書き、そのモジュールを使ううえで困らないだけの情報をIntelliSenseのツールチップ経由で提供しなければなりません。そういうベストプラクティスを行うための心構えに対しても、named exportは真っ向から反しています。IntelliSenseを使ってわざわざ探らなければならないような何かを、named exportの裏に「隠す」のですから。
タイポに対する防御(Typo Protection)
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#taiponisurutypo-protection
あなたは
import Foo from "./foo";
をしながら、他でimport foo from "./foo";
をするようなタイポをしたくないでしょう。
われわれにはESLintがあるのですから、
- ファイル名(
index.ts
ならディレクトリ名)と完全に同名の識別子のみdefault export可 - ファイル名(
index.ts
ならディレクトリ名)と完全に同名の識別子でのみdefault import可
とするルールを採用することができます。幸運なことに、この天上的ソリューションはすでに存在します(eslint-plugin-consistent-default-export-name
)。
TypeScriptの自動インポート
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#typescriptnoinpto
自動インポート修正は、うまく動きます。あなたが
Foo
を使うと、自動インポートはimport { Foo } from "./foo";
を書き記します。なぜなら、それはきちんと定義された名前がモジュールからエクスポートされているからです。いくつかのツールは、魔法のようにデフォルトエクスポートの名前を推論します。しかし、風変わりな魔法です。
もしあなたがanonymous default exportを使おうとするなら、これは問題になります。筆者は、default exportを避けるのをやめると同時に、anonymous default exportを避けるようにするべきだと思います。ちゃんと名前を定義してからdefault exportすれば自動インポートしてくれます。
eslint-plugin-import
にimport/no-anonymous-default-export
ルールが存在します。
再エクスポート(Re-exporting)
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#ekusuptore-exporting
再エクスポートは不必要に難しいです。再エクスポートはnpmパッケージのルートの
index
ファイルで一般的に行われます。例:import Foo from "./foo"; export { Foo }
(デフォルトエクスポート) vs.export * from "./foo"
(名前付きエクスポート)
この項目が書かれた時期が古いのかもしれませんが、現在ではこれはexport { default as Foo } from "./foo"
と書くことができます。これもESLintで同名のみ可能とするルールを作るのが今のところ適切な対処となるのではないでしょうか[3]。
ルートのindex
ファイルたった一つのためだけにプロジェクト全体を「汚染」するのは、浅はかとしか言いようがありませんね。
未だにStage 1 proposalであるtc39/proposal-export-default-fromの動向に期待です。これが導入されれば、export Foo from "./foo"
のように書くことができるようになります。
Dynamic Imports
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#dynamic-imports
デフォルトエクスポートは、
default
を動的にインポートしたときに、それ自身に悪い名前を付けます。例:const HighChart = await import('https://code.highcharts.com/js/es-modules/masters/highcharts.src.js'); Highcharts.default.chart('container', { ... }); // Notice `.default`
これは「CommonJSとの相互運用」セクションと同じ話です。これを避けたいがためにdefault exportをやめるというのは、本来やるべきトランスパイラ側での対処をせずにワークアラウンドにとどまっているだけであるという意識を持った方がいいでしょう。というか、リネームすればいいことですし。
非クラス/非関数の場合、2行必要です
https://typescript-jp.gitbook.io/deep-dive/main-1/defaultisbad#kurasuno-2-desu
関数/クラスに対しては、1行で書けます:
export default function foo() { }
名前が無い/型アノテーションされたオブジェクトに対しても、1行で書けます:
export default { notAFunction: 'Yeah, I am not a function or a class', soWhat: 'The export is now *removed* from the declaration' };
しかし、他のものに対しては2行必要です:
// If you need to name it (here `foo`) for local use OR need to annotate type (here `Foo`) const foo: Foo = { notAFunction: 'Yeah, I am not a function or a class', soWhat: 'The export is now *removed* from the declaration' }; export default foo;
こんなん言いがかりやろ[4]。
ふざけて粗探しをしているようにしか見えませんが、あえて真面目に応答するとすれば——ローカルスコープから何かをexportするというのは、そのために1行費やしてしかるべき重大な行為です。むやみにexportすべきではありません。理由はこれまでに述べたとおりです。
反論は以上です。
従属物のexportをどうするか
さて、ここまでdefault exportを持ち上げてきましたが、一つ問題が残っています。そのファイルの目的に対して論理的に密接な従属関係にあるものをexportしたいときはどうするべきでしょうか?
たとえば「Reactコンポーネント(そのファイルの目的)」に対する「propsの型定義(従属物)」などがそれです。この場合、型定義をnamespaceに入れるべきです。
namespace Component {
export type Props = {}
}
const Component = (props: Component.Props) => null
export default Component
型定義ではなく、従属的な定数などをexportしたい場合には、シンプルにプロパティを生やしてください。TSはトップレベルにおいてのみこの手の代入を許し、代入された側の型も更新します。
const Component = (props: {}) => null
Component.property = "foo"
export default Component
import側では以下のようにアクセスできます。
import Component from "path/to/react/components/Component"
Component.Props
Component.property
これはとても直感的に見えるでしょう。このようにすることで、定義間の論理関係を名前空間で表現することができるわけです。これは完全に有効な方法だと思います。「namespaced default export」とでも呼びましょうか。
加えて、ディレクトリ構造にも注意を払った方がいいです。これも名前空間の階層関係と似た構造を持っていますが、よりマクロな視座での階層関係です。この構造はパスに表れます。たとえば、components
ディレクトリに入っているものはReactコンポーネントである、ということをパスから具体的に推測することができます。あるimport
文が何をimportしているのか、パスだけ見れば把握できる状態が望ましいです。もしimport
文中にnamed exportされた識別子をずらずらっと並ベなければならないとしたら、そのモジュールファイルは整理されていない劣悪なモジュール構成を採用したファイルだと捉えるべきです。
しかし再三言いますが、ファイルの目的にとって論理的に関係がないものは、できるかぎり別のファイルに分けることを心がけてください。named exportしたくなったのなら、それはモジュール構成をリファクタリングすべき時が来たという合図です。
結び
まとめると、筆者の言い分はこうです。
- 基本的にdefault exportを使い、1ファイル1エクスポートをできるだけ徹底する。
- もし他のものをexportしたくなったのなら、
- それがdefault exportされる対象に従属するものなら、default exportされる名前空間に一緒に入れる。
- そうでないなら、別のモジュールに分けられないか考えてみる。
- それがどうしても無理そうなら、敗北者となってnamed exportする。
- もし他のものをexportしたくなったのなら、
- anonymous default exportは避ける。
- そのモジュールファイルのパスから推測できるものだけをexportするようにする。
もちろん、ユーティリティー関数のようなこまごまとした雑多なものを1ファイルにまとめてnamed exportするとかはまあ全然ありだと思います。ただ、その場合もあくまで「理想は1ファイル1エクスポート」で「理想から離れた必要悪としてすべてを1ファイルにまとめているにすぎない」という意識のもとでやるべきです。
定数1個、型定義1個をエクスポートするために新たなファイルを作成することを厭わないでください[5]。いちいち開かなければならないファイルの中身ではなく、一望できるディレクトリ構造を利用してプロジェクトの論理構造を表現しましょう。
Discussion