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を避けることは、〝ファイル=モジュール〟という発想と本質的に相容れない[1]」ということです。そういう考え方を採用しないつもりならdefault exportを避けることにしていいと思います。
もちろんこういった話は、結局のところ開発者がどれだけ注意深くモジュール構成を設計できるかということにかかっているのであり、named exportを実際に使うことがすぐさまプロジェクトの保守性を損なうことになると言っているのではありません。「named exportを躊躇いなくふんだんに使ってよい」というような気持ちでいることが問題なのです。
すべてをnamed exportにした場合でも適切に設計すれば問題ないかもしれませんが、最悪なケースでは、見通しの観点から言って、プロジェクト全体が単一のモジュールで構成されているのとさほど変わらないような状況に陥ってしまいます。つまり、ファイルという単位に本来与えられていたはずのモジュールという意味が失われていってしまい、どの定義がどこに入っているのかがめちゃくちゃになってしまいます[2]。もはや粒度とか以前の問題です。
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)
デフォルトエクスポートは検出性(Discoverability)が低いです。あなたはインテリセンスでモジュールを辿り、それがデフォルトエクスポートを持っているかどうかを知ることができません。
デフォルトエクスポートでは、あなたは何も得られません(それはデフォルトエクスポートを持っているかもしれませんし、持っていないかもしれません
¯\_(ツ)_/¯
):import /* here */ from 'something';
デフォルトエクスポートが無ければ、素晴らしいインテリセンスが得られます。
import { /* here */ } from 'something';
これはそもそもどういう問題意識なのか筆者はよく掴めていません。原語版でこの項目を追加した人自身、本当にこれで伝わると考えていたのでしょうか。みなさんはこれ理解できますか?
現代的なエディタでTypeScriptを使っているのなら、default exportの存在しないモジュールファイルをdefault importしようとしたらちゃんと認識して怒ってくれるはずです。
おそらくこれに関しては、二つ次の「タイポに対する防御(Typo Protection)」セクションと同じ解決策になりますので、そちらを参照してください。
オートコンプリート(Autocomplete)
エクスポートについて知っているか、いないかに関わらず、あなたはカーソル位置で
import {/*here*/} from "./foo";
をオートコンプリートできます。それはデベロッパーに少しの安心感を与えます。
この点も何が言いたいのか明らかではありませんが、おそらくimport {} from "./foo"
と書き切ったあとで{}
内にカーソルを移動し適当にタイプしたときのことを言っているのでしょうか。もしそうなら、それはそもそもnamed exportをむやみに使いまくることによって生じる弊害です。あるモジュールに何が入っているかわからないことが問題なのです。自分(named exportを濫用すること)で生み出した問題に自分で対処できないからって、default exportのせいにしないでもらいたいものです。
基本的にモジュールというのは、importする側にとってはブラックボックスに見えると思っておいた方がいいです[3]。少なくとも理想論としては、そういうつもりで引数に名前をつけ、ドキュメンテーションコメントを書き、そのモジュールを使ううえで困らないだけの情報をIntelliSenseのツールチップ経由で提供しなければなりません。そういうベストプラクティスを行うための心構えに対しても、named exportは真っ向から反しています。IntelliSenseのサジェストを使ってわざわざ探らなければならないような何かを、named exportの裏に「隠す」のですから[4]。
また、「default exportだと補完が効かない」と思っている人が多いようですが、それはanonymous default exportだからです。名前は付けてください。
タイポに対する防御(Typo 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の自動インポート
自動インポート修正は、うまく動きます。あなたが
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)
再エクスポートは不必要に難しいです。再エクスポートはnpmパッケージのルートの
index
ファイルで一般的に行われます。例:import Foo from "./foo"; export { Foo }
(デフォルトエクスポート) vs.export * from "./foo"
(名前付きエクスポート)
この項目が書かれた時期が古いのかもしれませんが、現在ではこれはexport { default as Foo } from "./foo"
と書くことができます。これもESLintで同名のみ可能とするルールを作るのが今のところ適切な対処となるのではないでしょうか[5]。
ルートの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;
こんなん言いがかりやろ[6]。
ふざけて粗探しをしているようにしか見えませんが、あえて真面目に応答するとすれば——ローカルスコープから何かを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個をエクスポートするために新たなファイルを作成することを厭わないでください[7]。いちいち開かなければならないファイルの中身に惰性でつらつらと定義を並べるのではなく、一望できるディレクトリ構造を利用してプロジェクトの論理構造を表現しましょう。
-
表面的には共存可能だが、概念的な方向性としては逆行している、くらいの意味 ↩︎
-
あるいは少なくとも、そういった状況を許すことになります ↩︎
-
そして実際、それを書いた本人以外の人にとっては、IntelliSenseを使って探ったり、ファイルの中身を見に行ったりしてみるまでは、文字通りブラックボックスです ↩︎
-
アンサー後追記: ただ、筆者の場合、サジェストを使って何かを探すのはガリガリコードを書いているときの自動インポート時であって、それはdefaultかnamedかに関係なく参照できる(その意味では別に隠されてない)ので、わざわざ
import
文を手で書くような時にサジェストを使う必要性はあまり感じていないんですよね。その意味で、このパラグラフは誤解しか招かないような書き方をしてしまった、と非常に猛省しています。「隠す」という批判表現で私が言いたかったのは、単に「見通しの観点からは、ファイル内にあるexportされない内部実装と大差がない(つまり一見隠れているように見える)」ということであり、「exportされて外から使われるものであるというなら、そのことがそのまま、(default exportで)同名のファイルとして存在させることによって視覚的に伝えられているほうがよい」といった設計上の筆者の選好にすぎない話でした。「named exportもモジュール間のコミュニケーションの手段として認められている」というご指摘はもっともです。こんなことは最初の段階で注を入れておくべきでした ↩︎ -
残念ながらそれをするプラグインはまだないようです ↩︎
-
いい感じのオチがついたのでむしろうれしいですが ↩︎
-
実際に作成すべき、と言っているわけではなく、そうしてもおかしくないという「つもり」でやっていくべき、という意味です ↩︎
Discussion
TS初心者のおっさんです。
色々と釈然としないところが多いのですが……極論すぎるように感じました。
default exportでもオブジェクトに纏めるとかpublicメソッド持ちのクラス定義で抜けられるので、強制力としては弱すぎませんか。
yuhrさんの中に「複数の口が生えているのはモジュールとは言えねぇ!正しくない!」ぐらいの価値観があるように見えますが、完全に行きすぎのように見えます。
自分は関数型に寄せたスタイルでやってて、クラスは一切使わず、(DBに置かずに直書きで済む程度の)辞書的なデータ、関数、型定義を大量に書く形で組んでいるのですが、その世界観だと特にビジネスロジックのモジュールって膨大な関数群をレイヤーとかドメインで割って収めたファイルになるんですよね。
それを1つのexport関数につき1ファイルで切るというのは明らかに細分化しすぎになってしまいます。
例えばめっちゃシンプルなAPIアクセスモジュールを定義します。
これはシンプルすぎてアレですけど、yuhrさんの説ですと、これも最初から4ファイルに割ることになってしまいます。
またnamed exportだとモジュール内の関数レベルでimportを制御できて、明確に追跡できて、tree shakingだって効いてくる訳ですよね。
例えばオブジェクトにまとめてから出すとか、あるいはクラス定義として出すとなると、クラスインスタンスの場合は補完や追跡はできるかもですが、ただのオブジェクトだったら無理で、tree shakingはどちらにせよ効かないでしょう。
そこらへんもあってdefault exportにするメリットが上回るとは感じられません。
あと考え方の相違として、一番断絶を感じたのが
低い検出性(Poor Discoverability)
オートコンプリート(autocomplete)
この2項目で、自分としては「モジュールには複数の関数が生えてるのが当たり前」なので、手動でimport部書く時にざっくり補完効いてくれたら嬉しいですよね。
そして問題はここ。
どういう口が生えてるか調べないといけないのを問題視されているようですが、default exportを使ったところで入出力の型を調べなきゃいけないので、手間として何も変わってなくないですか?複数ファイルに細分化された分、手間が増えてるまであるのでは。
ここは同意です。
ここに関しては完全に破綻しているように見えます。どこが反しているのかさっぱり分かりません。default exportだったら複数のファイルにコメント書かないといけないし、named exportだったら宣言部の真上に書かないといけないし、手間としては何も変わらないように思いますが……。
named exportにすると雑に複数の入出力を生やしやすくなって、数が増えることによりコメントとかもつけなくなってコード品質が下がる!とかそういう話でしょうか。それはシステムどうこうというより、習慣の話になるので……。
OOPどっぷりの所からWebフロントエンドに来て、癖のある風習に戸惑ってるとかならまだ分かるんですが、
GitHubを見た限り、TS+Nextどころか関数型言語も普通に触ってらっしゃるリテラシーの高い人が、これを言い出すのは(嫌味ではなく)純粋に驚きです。
ただ現状、「ぐちゃぐちゃになった他人のコードのお守りさせられてキレ散らかしているのかな?」と勘繰るぐらい、雑で納得できない文章になってしまっているので、もうちょっと丁寧に説明をお願いできませんでしょうか。
「抜ける」といった発想がまずおかしくて、それを言ってしまえば、TSの型チェックやESLintのヒントも単に無効にしてしまうことによってどんな劣悪な設計でもすり抜けられてしまいます。それはプロジェクトの保守性を壊す道に繋がっています。逆に、記事中で述べた「namespaced default export」のように、それを「抜け道」ではなく「活用法」として捉えるのが正しい道だと思います。この記事の内容は、強制することが目的なのではなく、より良い設計のための理念的なヒントでしかないです。
まあ、さすがにここは言葉が足りていなかったなと思います。「あらゆる点において相容れない」のではなく、「表面的には共存可能だが、概念的な方向性においては逆行している」くらいの意味合いです。すみませんでした⋯⋯。
私もそう思います。ただ、記事中で何度も「理念」や「気持ち」や「つもり」といったワードで説明しているとおり、これは理念上の話です。私自身、1ファイル1エクスポートが「明らかに細分化しすぎ」と感じるケースももちろんあります。どんな場合でも完全に細分化しようと言っているのではなく、細分化する方向に向かうことを基本理念としよう、ということがこの記事の結論部分の意味です。
tree shakingや追跡性で不便があるのは、エディタやバンドラの機能が不充分であることが問題です。これはそういったツールの方にコントリビュートして対処すべきです。もしその不便が原理的にツールの側で対処できない類のものである場合、それは今書こうとしているモジュール設計が間違っています。
ええと、まずもってむやみに「オブジェクトにまとめてから出す」「クラス定義として出す」のをやめようと言ってます。また、一つのファイルにまとめるべきであるくらい互いに密接に関わるロジックなら、そのモジュールを使ううえでそれらを余すところなく使うはずなので、tree shakingは不要です。もし、余すところなく使うわけではなさそうだが別のファイルに分けるのは難しい(もしくは手間である)というケースがあれば、それはnamed exportの出番です。named exportとは、ここまで考えて初めて選択肢にのぼるものであって、「まずはnamed export」とかはやめた方がいいと思います。
それを気持ちの上では当たり前でなくしていこう、というのがこの記事の主旨です。くどいようですが、実際に複数の関数が生えていたり、そういうモジュール構成にどうしてもなってしまうことが問題なのではなく、「モジュールにとりあえずなんでも詰め込んでしまう」という惰性による設計を避けようという話です。
補完に関しては、anonymousでないなら
import
の次にタイプし始めた時点でサジェストや補完が効いてくれるはずです(VS Codeの話ですが)。入出力の型を調べるとはどういう操作のことを言っていますか? モジュールに何が入っているかわからないという問題は型とは無関係ではないでしょうか。
修辞技法が悪さをしているようですみませんが、ドキュメンテーションコメントの話はあくまで、そのモジュールのユーザーにとっての障壁を最小限にするための実践の一例として挙げたまでです。「隠す」と言っているのは、「IntelliSenseのサジェスト経由で適当にタイプして探らなければならないような(障壁をわざわざ設けているような)形でexportする」という意味です。それは障壁を減らすという目標に反しています。コメントをどう書くかみたいな話はこの記事の主旨とは全く関係ありません。
いわゆる関数型言語では、default exportのような概念はたぶん存在してないですよね。named exportが一般的です。これもこの記事に書いたような問題があるため、注意した方がいい慣習だと思っています。
すでに記事中で述べているように、もし開発者が常に気をつけてモジュール構成を設計することができる(自信がある)のならそれで済む話で、exportの仕方など瑣末な問題です。私の書いた内容はあくまでヒントです。そこを取り違えないでください。
私が何かにキレ散らかしているとしたら、あのようなくだらない項目をデメリットとして挙げてdefault exportの価値を不当に貶めているTypeScript Deep Diveの元記事にですね。この記事のタイトルがその元記事をもじったものであることからも、これが元記事に対する逆張り記事であることがわかると思います。
結局どっちを使えばいいんだ😢 (楽だからという理由でよくdefaultを使ってます)
引用の仕様勘違いしてて、読み辛い状態ですいません。
大分ニュアンスや温度感が掴めてきました。
ビジョンとしては妥当だと思いますが、それだと原則default exportを推して、DeepDiveを徹底批判するより、
設計を解析してWarning出すような自作ライブラリを作って宣伝する方が、よほど筋が良いように思います。
補記なのでコメントに書きます。uhyoさんにアンサー記事を書いていただきました。ありがとうございます。私の記事のように大人げない攻撃的な論調ではなく、中立的で建設的な議論にまとまっており、たいへん助かります。
アンサーについて、細かい点で言いたいことがないわけではないです。
import/no-named-export
を使うようには勧めていないしかしながら、その本筋の一つである「named exportを適切に管理できない人がディレクトリ構造を適切に管理できるだろうという前提はあまり現実的でない」、という話は本当にそのとおりだと思います。
また、恥ずかしながら
eslint-plugin-import-access
というプラグインのことを知りませんでした。同じディレクトリにあるモジュールからのみimportできるようなexportを書くことができるルールです。便利ですね。ゆうやみさんも「設計を解析してWarning出すような自作ライブラリを作って宣伝する方が、よほど筋が良い」と仰られていますが、それも一理あると思います。実際にはこういった優れたESLintプラグインが存在していて、というか掘ればもっと(海外発とかでも)あるのかもしれません。
しかし私は「こんなもの作ったからみんな使ってね」というだけで済ませてしまうのではなく、一つの「指針」、考え方を提示したかったのです。作ったものを提示する記事は、往々にして宣伝や紹介記事だと捉えられてしまいます。そうなれば、そのプロダクトのメンテナンスが進まなくなった途端に、その記事の表面上の価値は薄れていく(ように見える)ことでしょう。特にフロントエンド界隈では情報が時代遅れになるのが早いですから、記事の作成日時やプロダクトの最終コミット日時を確認する習慣が、みなさんには根付いていると思います。紹介するプロダクトに引っ張られて、本質的な話が見落とされてしまうのでは本末転倒です。
named exportを下げてdefault exportを推すことが必ずしも本質的な解決へと繋がるわけではない、というのはすでに指摘されているとおりですが、私はそれでも、少なくともdefault exportがモジュールに中心点を与え、ファイルを作成する上での指針として機能すると考えています。ファイルに中心、すなわち単一の目的が存在することによって初めて、そのファイルをディレクトリに分類し整理することができるようになります(もちろん、default exportを使わないまでもファイルの目的が明らかであることもあるでしょうけど)。見通しのよいディレクトリ構造(≒プロジェクト)へ必ずしも繋がらなくとも、その布石としてdefault exportを使うことができると思っています。
この記事を書いた時点では、さすがにTech Trendingの一番上にまで来るとは思っていませんでしたが、逆張り記事という体でこうした扇情的なタイトルにしたことで、結果的にみなさんの目に触れ、良くも悪くも「考える」という機会をもたらすことができたと思います(逆に論調によって考えを乱されることがあることも認識しています)。みなさんの読後感や読解に費やした時間に責任を持つためにも、今さら胡乱で直裁な表現を穏当なものに置き換えるつもりはありませんが、意味を読み取りづらくなっていることについては本当に申し訳なく思っています。