atama plus techblog
😲

default exportとnamed exportって結局どう使い分ければ良いの?

2024/12/02に公開

こんにちは、atama plusというスタートアップでwebエンジニアをしているyubonです。
atama plus Advent Calendar 2024の12月2日の記事になります!

はじめに

JavaScriptにおけるexport宣言には、default export(デフォルトエクスポート)とnamed export(名前付きエクスポート)の2種類があり、そのどちらを使うべきかについては今までもさまざまな記事で議論がなされています。
それらの記事に書かれた内容も踏まえた上で、本記事では新たに

  • default exportとnamed exportの思想の違い
  • 執筆時点(2024年11月)の周辺環境(IDEなど)の進歩も踏まえた実務上のメリデメ

などに触れつつ、自分なりに再調査した結果をまとめてみました。

なお、例に挙げるコードは以下を前提に記述していきます。

  • 言語:React+TypeScript
  • export方式:ES Module
  • エディタ・IDE:Visual Studio Code

2つのexport宣言の思想の違い

流れの都合上、named export -> default exportの順で説明します。

named export

named exportは、モジュールが複数の関連した機能(=変数や関数、クラスなど)を持っていた場合に、その名の通りそれぞれに名前を付けて個別にエクスポートできるという方法になります。
名前を付けるからには一意に特定できる必要があるので、複数の機能で名前が重複した状態でエクスポートすることはできません。

sample-module/index.ts
export const name = 'taro';
export function hogeFunction() { /* ... */ }
export class FugaClass { /* ... */ }

importする側でも基本的にエクスポートされた名前でインポートすることになります。
importする側でモジュールからどの機能をインポートするか選択できるため、必要なもののみインポートすることも可能です。

import { name, hogeFunction } from "sample-module";

つまり、named exportを使った場合は
このモジュールでは関連した多くの機能を提供しているので、必要なものを選択して使ってね。(名前を決めることで)使い方はこちらである程度決めておいたよ
という意思表示をしているという考え方ができます。

default export

default exportは、モジュールの中で1つの機能のみエクスポートするという方法になります。

sample-module/index.ts
export default function hogeFunction() { /* ... */ }

1つの機能しかエクスポートされないので、名前を省略することも可能です。
名前を省略したdefault exportはanonymous default export(匿名デフォルトエクスポート)と呼ばれます。
(反対に、明示的に区別するために、機能の名前を付けた状態でのdefault exportはnon-anonymous default export(非匿名デフォルトエクスポート)と呼ばれることがあります。)

sample-module/index.ts
// ↓anonymous default export
export default function () { /* ... */ }
// ↓non-anonymous default export
// export default function hogeFunction() { /* ... */ }

そのモジュールをimportする場合は使いたい機能は1つに決まっていることになります。
また、importする側で名前を指定してインポートする必要があります。

import sampleHoge from "sample-module";

つまり、default exportを使った場合は
このモジュールでは最重要な機能を1つだけ提供しているから、ぜひその1つを使ってね。(名前は可変なので)その機能をどう捉えるかはある程度そちらに任せるよ
という意思表示をしているという捉え方ができます。

〜余談 Part1〜

named exportされたモジュールの機能も名前を変更できる?

named exportされたモジュールの機能もasを使うことでimport側で名前を変更できます。

import { hogeFunction as hoge } from "sample-module";

一度exportされた時の名前(hogeFunction)でインポートした上で、asでエイリアス(hoge)を付けていることが見て取れます。
名前を変更できると言っても、exportされた時の名前と同じ名前で一度importしなければならず機能名の依存関係が直接的に保たれているという点で、default exportで名前が指定できることとは意味合いが異なっているということがわかります。

なお、このasを使ってimport側で名前を変更できる性質は、複数のモジュールからimportした機能の名前が重複した時にコンフリクトを解決を行うのに役立ちます。

import { Request as hogeRequest } from "hoge-module";
import { Request as fugaRequest } from "fuga-module";

default exportも実はnamed export?

default exportは下記のような書き方もできます。export default ... という書き方はこの書き方の省略形になります。

sample-module/index.ts
function hogeFunction() { /* ... */ }
export { hogeFunction as default };

これはある種、機能をdefaultという名前でnamed exportしており、(named exportの項で触れた通り)複数同じ名前ではエクスポートできないという制約を使って

モジュールの中で1つの機能のみエクスポートする

を実現しているとも捉えられますね。(非常に面白いですね)

なお、機能がdefaultという名前でnamed exportされていると捉えられることから分かるように、import側でも同様の書き方が可能です。

import { default as hoge } from "sample-module";

export側と同様に、import hoge from ... という書き方は上記の書き方の省略形になります。

default exportとnamed exportは両立できる?

1つのモジュールから1つの機能をdefault export、その他の機能をnamed exportするという形で両方を使うことも可能です。

sample-module/index.ts
const mainVariable = 'main';
export default mainVariable;

export function subFunction() { /* ... */ }
export class SubClass { /* ... */ }

両方を使うことで、最重要の機能(=default exportされているもの)とそれに従属している機能(=named exportされているもの)の違いを明確に意思表示できます。

なお、上記の例ではそれぞれ別々にエクスポートしていますが、まとめてエクスポートを行うこともできます。
default exportに関しては、前項で述べたas defaultを使います。

sample-module/index.ts
const mainVariable = 'main';
function subFunction() { /* ... */ }
class SubClass { /* ... */ }

export { mainVariable as default, subFunction, SubClass };

変数のdefault exportはなぜ1行で書けない?

default exportでは変数をexport default ... という書き方でエクスポートすることはできません。

sample-module/index.ts
export default const name = 'taro';
// -> 🙅‍♀️ errorが出る
sample-module/index.ts
const name = 'taro';
export default name;
// -> 🙆‍♀️ errorは出ない

なぜできないかというと、複数の変数を同時に宣言できるからだと考えられます。
default exportの「モジュールから最重要な機能を1つだけ提供する」という考え方に反することになってしまうためです。

sample-module/index.ts
export default const name = 'taro', age = 16;
// -> 🙅‍♀️ 複数エクスポートできてしまうのはよくない

なお、これまでの例で出てきたように、関数やクラスは同時に複数宣言することはできないため、export default ... という書き方で宣言できます。

実際に利用する上での論点

ここまで2つのexport宣言の思想の違いに関して見てきました。
ここからは他記事でも取り上げられていますが、実際に利用する際に出てくるメリデメについて論点ベースで触れていきたいと思います。

export側変更時にimport側が影響を受けやすいかどうか

名前が変更できる/できないという性質はexportしている側にどのような変更が入るかによって、メリットにもデメリットにもなりうると感じました。
その点について例を交えて以下で詳しく説明していきます。

default export でメリットが享受できるケース

例えば以下のような状況があったとします。

table.tsx
const Table = () => { /* ... */ };
export default Table;
index.tsx
import MemberTable from './table';
  • table.tsxTableコンポーネントを定義してdefault exportしている
  • index.tsxにおいてはこのテーブルはメンバーの一覧を表示するためのTableコンポーネントなのでMemberTableと名前を変えて定義した

上記の状況で、import元のTableが何らかの影響でNewTableに名前を変更しなければいけなくなったとします。

table.tsx
const NewTable = () => { /* ... */ };
export default NewTable;

その場合、MemberTableに名前を変更しているindex.tsx側は影響がなく、修正が発生しません。

index.tsx
// このままでOK!
import MemberTable from './table';

対比のために、同様のケースでnamed exportをしていた場合も考えてみます。
同様にTableという名前でimportしていた状況から、import元のTableNewTableに名前を変更された場合、import側ではNewTableに変更しなければなりません。

table.tsx
export const NewTable = () => { /* ... */ };
index.tsx
import { Table } from './table';
// -> Error: Module '"./table"' has no exported member 'Table'.

上記のようにTableの部分でエラーが発生してしまいます。

補足
とはいえ、エディタ・IDE の rename 機能を使うことで一括で置換してくれるため、実際は修正の手間などはほとんどかかりません。(強いてマイナスポイントを挙げるとするなら、import している箇所が多いと変更差分が増えてしまってレビューが大変ということくらいですかね...)

named export でメリットが享受できるケース

こちらも新しい例を用いて説明します。

date-util.ts
export const convertToYYMM = (date: Date) => { /* ... */ };
index.ts
import { convertToYYMM } from './date-util';
  • date-util.tsDateオブジェクトから年月の文字列を取り出すconvertToYYMMメソッドを定義してnamed exportしている
  • index.tsにおいてconvertToYYMMとしてimportした上でDate -> stringへの変換処理に使用している

上記の状況で、import元のconvertToYYMMでYYMMDD形式で返すようにしたくなったため、convertToYYMMDDに名前も変更するとします。

date-util.ts
export const convertToYYMMDD = (date: Date) => { /* ... */ };

その場合、index.ts側でエラーが出るため変更が検知でき、convertToYYMMDDに変更すべきなのか別メソッドを使うべきなのかの判断をできます。

index.ts
import { convertToYYMM } from './date-util';
// -> Error: Module '"./date-util"' has no exported member 'convertToYYMM'.

対比のために、同様のケースでdefault exportをしていた場合も考えてみます。(※ファイル名をdefault exportに適した名前に変更しています)

convert-to-yymm.ts
const convertToYYMM = (date: Date) => { /* ... */ };
export default convertToYYMM;

同様にconvertToYYMMという名前でimportしていた状況から、import元のconvertToYYMMconvertToYYMMDDに名前を変更された場合、import側ではconvertToYYMMのままでもエラーにはなりません。

convert-to-yymmdd.ts
const convertToYYMMDD = (date: Date) => { /* ... */ };
export default convertToYYMMDD;
index.ts
import convertToYYMM from './convert-to-yymmdd'; // -> No Error!

それによって名前とは実態が異なるロジックで関数が実行されてしまうことに気がつけないリスクがあります。

補足
とはいえ、今回のように default export をしている名前でそのまま import しているケースでは、実はエディタ・IDE の rename 機能で一括で置換してくれます。
ただし、default export をしている名前から変えている場合は置換はされないため、残念ながら上記問題は発生してしまいます。

検索(grep)がしやすいかどうか

検索(grep)のしやすさという観点ではimport先でも名前が同じnamed exportのほうが優れていると言えます。

ただし、default exportの場合でも(名前を変更できるメリットは手放すことになりますが)import先の名前をESLintで固定することはできます。
https://www.npmjs.com/package/eslint-plugin-consistent-default-export-name
上記のESLintでは「ファイル名(index.tsならディレクトリ名)と完全に同名の識別子のみdefault export可」というルールを設けることでそれを実現できるみたいです。

エディタ・IDE で入力補完が効くかどうか

named exportではもちろんエディタ・IDEで入力の補完が効きます。

apple.ts
export const apple = 'apple';

import-apple-module
named export の入力補完

default exportではエディタ・IDEで入力の補完が効かない、そしてそれは誤りでnon-anonymous default exportでは補完が効くよという過去の記事がありましたが、
VSCodeで試してみたところ、anonymous default exportでも最近は入力の補完が効くようになったようです。

banana.ts
const banana = 'banana';
export default banana;

import-banana-module
non-anonymous default export の入力補完

cherry.ts
export default 'cherry';

import-cherry-module
anonymous default export の入力補完

VSCodeの場合、anonymous default exportではファイル名をキャメルケースに変換した文字列としてsuggestされるみたいです。
(例:cherry-blossom.tsファイル -> cherryBlossomモジュール)

React.lazy が使用できるかどうか

Reactにはコンポーネントの遅延読み込みをするためのReact.lazyという機能があります。

import { lazy } from "react";

const Table = lazy(() => import("./table"));

この機能はdefault exportを想定されていますが、named exportの場合も一工夫すれば利用できるようになります。

React.lazy公式ドキュメントを見てみると、lazyが引数に受け取る関数は以下のような仕様になっていればいいようです。

load 関数
引数
load は引数を受け取りません。
返り値
Promise または何らかの thenable(then メソッドを持つ Promise のようなオブジェクト)を返す必要があります。
最終的に、有効な React コンポーネント型、つまり例えば関数、memo、または forwardRef コンポーネントのようなものを .default プロパティとして持つオブジェクトに解決される必要があります。

  • thenメソッドを持つPromiseのようなオブジェクトを返す
  • 有効なReactコンポーネント型を.defaultプロパティとして持つオブジェクトに解決される

この2点を守ればよいので、named exportしているモジュールにおいても以下のような書き方をすれば対応が可能です。

import { lazy } from "react";

const Table = lazy(() =>
  import("./table").then((module) => ({ default: module.Table }))
);

lazyを使いたい箇所が数箇所程度であれば上記対応方法でも良さそうですが、数が増えてくると面倒かと思います。
utilityを自作するのもいいですし、それも面倒な人向けにreact-lazilyという軽量なライブラリもあるそうです。以下の記事が参考になったのでご覧になってみてください。

https://zenn.dev/bmth/articles/react-lazily

まとめ:結局どちらをどう使えば良いのか?

結局どちらをどう使えば良いのかについて、ここまで述べてきた思想の違いと利用上の論点を踏まえてまとめます。

default export(only)

思想

「このモジュールでは最重要な機能を1つだけ提供しているから、ぜひその1つを使ってね。(名前は可変なので)その機能をどう捉えるかはある程度そちらに任せるよ」

利用が適したケース

  • 1ファイル1モジュールとして責務を明確にしていこうという設計方針を設けているケース
  • exportする側とimportする側が独立しており、以下に該当するケース
    • export側としてはimport側の命名を意識したくない
    • import側としてはexport側の変更に振り回されたくない

例:ライブラリや基盤部分(ライブラリのインタフェースの名前が頻繁に変わったりする場合に、使う側がそれをいちいち意識・修正しなければならないのは大変なので)

default export + named export

思想

「このモジュールでは最重要の機能とそれに従属している機能を明確に分けて提供しているので、必要なものを選択して使ってね。」

利用が適したケース

  • 1モジュールから複数の機能をエクスポートしたい、かつ、機能にメインとサブの位置付けを明確に付けたいケース

例:主となるモジュールとその従属物(例えば、React+TypeScriptであればコンポーネントとそのpropsの型をエクスポートしたいことも多いはず)

named export(only)

思想

「このモジュールでは関連した多くの機能を提供しているので、必要なものを選択して使ってね。(名前を決めることで)使い方はこちらである程度決めておいたよ」

利用が適したケース

  • 1モジュールから複数の機能をエクスポートしたいケース
  • exportする側とimportする側の双方を管理できて、以下に該当するケース
    • export側としてはimport側で命名を変更しないでほしい
    • import側としてはexport側が変更されたら検知したい

例:ユーティリティ関数などの関連した並列の機能(例えば、日付を色々なフォーマットに変換した文字列で返す関数群をまとめたモジュール)

〜余談 Part2〜

named exportはモジュールの責務がブレるからやめたほうがよい?

よくnamed exportではいくらでもモジュールをexportできてしまうので、1ファイルに関係ないものも詰め込んでしまってファイル単位での責務がブレてしまうのでは? という主張を見かけます。
個人的にはどちらのexport宣言を使うのかと、責務を意識して設計できない問題は別で考えてもよいのではないかなと思っています。
結局default exportを選択した場合は1ファイル1モジュールにするためにファイルが増えていくわけで、それらのディレクトリ構造を適切に設計できるかどうかという問題に直面する気がしています。

この辺りの話はuhyoさんのアンサーブログに丁寧に書いてあったため、興味がある方はご覧になってください。
https://blog.uhy.ooo/entry/2021-09-09/answer-named-export/#宣伝

そもそも2つのexport宣言を使い分ける?

モジュールの性質によって使い分けるとよさそうという話を述べてきました。
しかし、チームで開発を行っていく場合は、複雑すぎるルールは徹底が難しいため、チームで話し合って割り切ってどちらか一方に統一するというのもアリだと思います。

ルールを決めたら仕組み化することが大事だと思うので、ついでにESLintも紹介しておきます。

自分が携わっているプロダクトの方針と個人的な所感

現在自分が携わっているプロダクト(React+TypeScript)では、ファイル内に複数の責務を持ち込まないように意識することを大前提とした上で、現時点ではnamed exportで統一する方向で進めようと考えています。

  • モジュールの性質に合わせて使い分けられるのがベストですが、開発メンバー1人1人が意識するには認知負荷が高そう&結局解釈がバラついて徹底が難しそう
  • そのためどちらか一方に統一したく、React+TypeScriptだとコンポーネントとそのpropsの型をエクスポートしたいことも多かったため、default exportのみだと運用が難しそう

こういった方針は選定している技術やプロダクトの性質、メンバーの規模感に合わせて設計することが必要だと思います。
状況次第で変化することもあり、一概にこれが絶対良いと言い切れないのは難しくもありますが、考えがいがあって楽しいなとも感じます。

参考にさせていただいた記事たち

中立的な説明
named export派
default export派
その他

おまけ

本記事のタイトルは弊社の若手スーパーエンジニアのyutake27が書いた以下の記事のタイトルをオマージュしたものになります。

とてもわかりやすくタメになる記事なのでぜひこちらもご一読ください。

atama plus techblog
atama plus techblog

Discussion