みなさんこんにちは。今日は、TypeScriptの新しいコンパイラオプション(おそらく4.7で導入)であるmoduleSuffixesについての話題がTwitterで見られました。
moduleSuffixesについて詳しくはこちらをご参照ください。
これについては、「モジュール解決がさらに複雑化する」などいくつかの方向性から否定的な意見が見られました。しかし、筆者が考えてみたところ、正当性のある機能追加だと納得できたので考えをご紹介します。
3行でまとめると
- これまで通りランタイムの挙動に影響しないから大丈夫だよ
-
paths
が怖くないならmoduleSuffixes
も怖くないよ - TypeScriptはJavaScript環境に追随するよ
moduleSuffixesとは
では、moduleSuffixes
はどんなコンパイラオプションなのかという解説をまず少しします。これはTypeScriptが行うモジュール解決に新たな拡張性を追加するオプションです。モジュール解決に関わる既存のオプションとしてはmoduleResolution
やpaths
, baseUrl
などがあります。moduleSuffixes
は"moduleResolution": "node"
時のモジュール解決(今時のプロジェクトは大体こっちです)に影響を与えます。
これまでのモジュール解決
TypeScriptでは次のように他のファイルをインポートできることはご存知の通りです。
import something from "./foo";
これは「同じディレクトリにあるfoo
からインポートする」という記述ですが1、では「同じディレクトリにあるfoo
」の実態は何でしょうか。そう、foo.ts
のことです2(JSXが有効の場合はfoo.tsx
も可ですが、この記事では簡単のために以降JSXのことは省略します)。このように、「"./foo"
」のような記述から具体的にどのファイルをインポートしているのかを特定するのがモジュール解決です。
これまでのTypeScriptでは、このように拡張子なしで相対パスを指定した場合は.ts
を補ってファイルが探索されていました。つまり、./foo
に対してはfoo.ts
やfoo.tsx
といったファイルが候補として探索されます。
moduleSuffixesは何をするのか
moduleSuffixes
は例えば次のように、配列を指定します。次の例はPRからの引用で、React Nativeでのユースケースを意識した例です。
"moduleSuffixes": [".ios", ".native", ""]
こうした場合、ファイル名にここで指定されたsuffix(接尾辞)をつけたファイル名も探索対象になります。つまり、./foo
の場合、foo.ios.ts
、foo.native.ts
、foo.ts
の3つのファイルが探索対象になります。
// foo.ios.ts があれば foo.ios.ts に解決され、
// foo.native.ts があれば foo.native.ts に解決され、
// どちらもなければ foo.ts に解決される
import something from "./foo";
余力がある方は実験してみましょう。まず、次のような index.ts
を用意します。
import something from "./foo";
次に、 foo.ts
を用意します。
export default 123;
するとindex.ts
のsomething
はnumber
型になります。
その状態で、foo.native.ts
を追加してみましょう。
export default "uhyohyo";
すると何ということでしょう、something
はstring
型に変わります。これは、./foo
の解決先がfoo.ts
からfoo.native.ts
に切り替わったからです。
さらにfoo.ios.ts
を次のように追加すると、something
はboolean
型に変わります。面白いですね。
export default false;
moduleSuffixes
の機能は以上です。
moduleSuffixesは何をしないのか
moduleSuffixes
は、トランスパイル結果には影響しません。foo.ios.ts
があろうが無かろうが、index.ts
のトランスパイル結果は次の通りです("module": "esnext"
の場合)。
import something from "./foo";
つまり、moduleSuffixesの設定によって、トランスパイル結果の./foo
が./foo.ios
とか./foo.ios.js
とかに書き換えられることはないということです。
moduleSuffixes
が影響するモジュール解決はあくまでTypeScriptの型検査のためだけに用いられます。先ほどの例で言えば、「something
の型をどのファイルから取ってくるか」ということだけがmoduleSuffixes
の影響を受けるのです。
.js
の逸話
TypeScriptではESM環境への対応がややつらいという話は有名です。例えばnode.jsで"type": "module"
とした環境下で動くTypeScriptコードを書きたければ、次のようにする必要があります。
import something from "./foo.js";
TypeScriptなのに、インポート元が .js
という拡張子になっています。こうする必要がある理由は、トランスパイル後のimport文でも"./foo.js"
となっている必要があるからです。Node.jsのESM環境では、CommonJSの場合とは異なり、import時にファイル名の拡張子を省略できません。そのことがTypeScriptコードにも影響しています。
TypeScriptはimport文をトランスパイルする際に、specifier("./foo.js"
部分)を書き換えません。そのため、トランスパイル後を.js
にするためには、上のコードのようにトランスパイル前も.js
にする必要があるのです。
TypeScriptはこの挙動のために、わざわざ「.js
という拡張子は.ts
にモジュール解決する」という仕様を備えています。この仕様により、./foo.js
と書いてもTypeScriptはfoo.ts
を見に行きます。TypeScriptはfoo.ts
をトランスパイルしてfoo.js
を出力するので、こうすることでランタイムに辻褄が合うことになります。
このような挙動となっている理由は、「TypeScriptの型検査はランタイムの挙動(=トランスパイル後のコード)に影響を与えない」という大原則のためだと思われます。モジュール解決も一応型検査の一部であることを踏まえて、TypeScriptが実施したモジュール解決の結果によってトランスパイル結果に影響を与えないようになっているのです3。
moduleSuffixesと類似のオプション
moduleSuffixes
と類似した用途を持っているコンパイラオプションとしては、paths
が挙げられます。例えば次のような設定を考えてみます。
"paths": {
"~/*": "./src"
}
こうすると、次のようなimport文が可能になります。
import something from "~/foo";
"~/foo"
というspecifierは、paths
の設定に従い、(tsconfig.jsonから見て)./src/foo.ts
の位置にあるファイルにモジュール解決されます。モジュール解決を操作し、specifierの意味を規定するという点で、moduleSuffixes
はpaths
ととても類似しています。paths
よりはmoduleSuffixes
のユースケースは限定されそうですが、怖さという点ではmoduleSuffixes
はpaths
と同じくらいと評価できるでしょう。
なお、この場合のトランスパイル結果は次のようになります。specifierを書き換えないという原則に従って、~/
といった記述も書き換えられません。~
はトランスパイル後のJavaScriptを実行する側で別途なんとかしてあげる必要があります。このことが次の話題につながります。
import something from "~/foo";
ランタイムのモジュール解決は誰の責務なのか
TypeScriptは、トランスパイル時にimport宣言のspecifierを操作しません。このことについてもう少し考察してみましょう。
そもそも、specifierの意味はどのように決まるのでしょうか。言い換えれば、import something from "./foo.js";
と書いたときに./foo.js
という文字列が「同じディレクトリに存在するfoo.js
というファイル」という意味になるということはどこで定義されているのでしょうか。
意外なことに、これはECMAScript仕様書ではありません。ECMAScript仕様書 (2022年4月5日版)からspecifierの意味に関わる部分を引用すると、次のように書いてあります。
16.2.1.7 HostResolveImportedModule ( referencingScriptOrModule, specifier )
The host-defined abstract operation HostResolveImportedModule takes arguments referencingScriptOrModule (a Script Record, a Module Record, or null) and specifier (a ModuleSpecifier String) and returns either a normal completion containing a Module Record or an abrupt completion. It provides the concrete Module Record subclass instance that corresponds to specifier occurring within the context of the script or module represented by referencingScriptOrModule. referencingScriptOrModule may be null if the resolution is being performed in the context of an import() expression and there is no active script or module at that time.
(筆者による訳)host-definedな抽象操作である HostResolveImportedModule は引数 referencingScriptOrModule (Script Record、Module Recordまたはnull)とspecifier(ModuleSpecifier文字列)を引数に受け取り、Module Recordを含むnormal completionまたはabrupt completionを返します。この抽象操作は、referencingScriptOrModuleで表されるスクリプトまたはモジュール内に書かれたspecifierによって参照される、具体的なModule Recordのサブクラスのインスタンスを与えます。アクティブなスクリプトまたはモジュールが無い状態でimport()式によってモジュール解決が行われる場合、referencingScriptOrModuleはnullとなる可能性があります。
要するに、specifierが具体的にどのモジュールに(ランタイムのimport宣言等の挙動として)解決されるかということを定義するのがここで引用されているHostResolveImportedModuleなのですが、この抽象操作はhost-definedとされ、具体的な挙動が書かれていません。host-definedというのは読んで字のごとく、「ホストによって定義される」ということです。ホストとは、簡単に言えば実行環境のことです。
つまり、JavaScriptが動く環境でも、specifierの具体的な意味は実行環境によって異なる可能性があるということです。
具体的なホストの例として、Node.jsやDenoが挙げられます。例えば、次のようなJavaScriptプログラムはNode.jsでは動きますがDeno環境では(DenoのNode.js互換モードを使わなければ)動かないでしょう。しかし、これはNode.jsとDenoのどちらかが間違っているわけではなく、どちらもECMAScript仕様に準拠していると言えます。ホストとして定義する部分が違っているというだけの話なのです。
// 組み込みモジュールからインポート
import { readFile } from "fs";
// node_modulesからインポート
import express from "express";
ちなみに、意外なことに、ブラウザはECMAScript仕様で言うところのホストには当てはまりません。なぜなら、ブラウザはHTML仕様に縛られており、HTMLから実行されるJavaScriptについては、JavaScriptのhost-definedな部分はHTML仕様の内部で定義されているからです(8.1.5 JavaScript specification host hooks)。つまり、ブラウザ環境では、HTML仕様そのものがホストであると言えます。実際に、"./foo.js"
のようなspecifierがブラウザ上ではどのように解釈されるかと言うことは、HTML仕様にちゃんと書いてあります。
バンドラとモジュール解決
フロントエンドのJavaScirpt/TypeScript開発では、Webpackに代表されるバンドラが常用されます。
実は、バンドラも部分的にECMAScript仕様に対するホストとして振舞います。なぜなら、バンドラは独自にimport
やexport
を解釈し、それに従ってファイルをまとめるからです。バンドラがファイルをまとめることは、JavaScriptプログラムの実行の一部(具体的に言えばimport/exportの解決)をエミュレートし、事前に行うという最適化であると見なせます。この意味で、バンドラもJavaScriptの実行環境の一種であり、import/exportの解決に関してはECMAScript仕様書で言うところのホストとしての役割を担っているのです。
Webpack向けのTypeScriptコードでは、次のようにファイル名の拡張子を省略するのが通例でした。
import something from "./foo";
これが許される理由は、「"./foo"
を同じディレクトリのfoo.ts
に解決する」という挙動をWebpackが持っている(より正確に言えば、WebpackのユーザーがそのようにWebpackのconfigを設定する)からです。
TypeScriptはモジュール解決のホストではない
ここでTypeScriptに話を戻しましょう。TypeScriptは型検査のための独自のモジュール解決を行いますが、ランタイムのモジュール解決には一切関与しません。これは、TypeScriptはECMAScriptの意味では、モジュール解決のホストではないということです。
よって、TypeScriptにおけるspecifierの書き方というのは、TypeScriptそのものによって規定されるのではなく、TypeScriptコードが(JavaScriptに変換後に)実行される環境に依存します。だからこそ、同じTypeScriptであるにもかかわらず、Webpackの場合は ./foo
で良かったものがNode.jsのESM環境では./foo.js
にしなければいけない、というような違いが生まれます。
もちろん、TypeScriptではなく素のJavaScriptでも事情は同じです。TypeScriptはランタイムのモジュール解決という点ではJavaScriptと同じ立場にいるのです。
TypeScriptとモジュール解決の設定
moduleSuffixes
に話を戻しましょう。TypeScriptはランタイムのモジュール解決には関与しない(ホストではない)ことは前述の通りですが、それはそれとして、この記事の前半で述べたように、TypeScriptは型検査のために独自のモジュール解決を行う必要があります。moduleSuffixes
(やpaths
など)はこちらをカスタマイズするためにあります。
型の世界とランタイムの世界で食い違わないためには、ECMAScriptホストによって行われるモジュール解決の挙動とTypeScriptが行うモジュール解決の挙動が一致している必要があります。TypeScriptの"moduleResolution": "node"
というコンパイラオプションは、TypeScript側のモジュール解決の挙動とNode.jsのモジュール解決の挙動を一致させるという意味であると理解できます。
とはいえ、Webpackなどのバンドラ環境でも"moduleResolution": "node"
を使うのが通例となっています。これは、Node.jsとはいえCommonJS時代の挙動がベースとなっており、バンドラ側もそれをベースに発展したからであると考えられます。CommonJS環境でもWebpack環境でも「拡張子を省略する」という共通の書き方が通用するため、これがデファクトスタンダートとして運用されてきたというのが実態でしょう。
これにより、このような環境においてTypeScriptではあまりモジュール解決周りの設定を頑張らなくても何とかなります。ネイティブES Modulesへの移行における辛さは、このデファクトスタンダードから外れる(あるいは新しいデファクトスタンダードへと移行する)ことの辛さであると言えます。
TypeScriptは本質的に、ランタイムの挙動がすでにあるJavaScriptに対して後付けで型を備え付けたものであり、JavaScriptで生まれる様々なニーズに対して後付けで対応してきました。モジュール解決に関してもそれは同じことです。例えば、バンドラ環境では、少し前に登場した ~/
のような記法によるモジュール解決がよく行われます。これに対応するTypeScript側の概念がpaths
であり、バンドラ側の設定に合わせたpaths
を書くことによってこのような(JavaScriptレイヤーから来る)ニーズに対応できます。
余談ですが、WebpackとTypeScriptでこの設定を同期させるのが大変だという動機でtsconfig-paths-webpack-pluginが広く使われていますが、筆者個人的にはこれはあまり使いたくありません。このプラグインはJavaScriptが主・TypeScriptが従という関係を逆転させてしまう点が気に入らないからです。
moduleSuffixes
の追加は、JavaScriptレイヤー(ランタイムのモジュール解決)での需要がさらに多様化したことに呼応し、TypeScript側のモジュール解決を拡張するものであると解釈できます。その意味で、このオプションの追加は特段突飛なものではなく、「JavaScriptの発展に合わせてTypeScriptが進化する」という普段通りの潮流の一部であると解釈できます。この考えから、筆者はmoduleSuffixes
に対して比較的好意的です。
moduleSuffixes
の主要なユースケースとして挙げられているのは、issueなどにも書かれている通りReact Nativeです。React Nativeも独自のランタイムのモジュール解決システムを持っており、そこでのTypeScriptの利便性がmoduleSuffixes
によって向上するでしょう。
一応、moduleSuffixes
は名指しで「React Nativeモード」のような設定ではなくある程度の一般性を持った機能になっています。Design Meeting Notes, 3/2/2022などではややReact Nativeと癒着ぎみではないかという懸念も記されていますが、十分な一般性があるとして実装に踏み切ったようです。
筆者が感じる現状の良くない点としては、「モジュール解決」の分野で十分な汎用性を持ったデファクトスタンダードが無いことです。Webpack, Vite, そしてTypeScriptなどが独自にモジュール解決の設定項目を定義し発展しています。一発書けばどこでも通用するデファクトスタンダードなフォーマットがあっても良いのではないかと思います。
まとめ
この記事ではmoduleSuffixes
の概要について解説し、ECMAScriptのホストの概念に触れながらTypeScriptにおけるモジュール解決オプションの意義を考察しました。モジュール解決の領域においてTypeScriptはあくまで“従”の立場にあり、設定の複雑化と戦いつつもJavaScriptの発展に追随しているのです。
-
厳密に言えば、「現在の慣習に従えば」という枕詞が付きます。なぜなら、この話はこの記事の後半の話題とも関わってきますが、import specifierのセマンティクスを決めるのはECMAScript仕様ではなく各ホストだからです。 ↩
-
将来的には
.cts
や.mts
のサポートが計画されていますが、それはこの記事とは近いものの別の話題です。 ↩ -
「そうは言っても機械的に
.js
を付けるだけじゃん」と思われたかもしれませんが、そうでもありません。JSXの例を掘り返すと、TypeScriptでは./foo
はfoo.ts
に解決されるかもしれないしfoo.tsx
に解決されるかもしれません。それに応じて./foo.js
とか./foo.jsx
を出力するのは、コンパイル時のファイルシステムの状況に応じてランタイムの挙動が変わってしまうので望ましくありません。 ↩