この投稿では、ライブラリをnpmで公開する際に、TypeScriptソースコードを同梱する場合、どのようなディレクトリ構成が良いかを説明するものです。ライブラリ開発者の皆様にとって、役立つ情報になればいいなと思います。
結論
ライブラリをnpmで公開する際に、TypeScriptソースコードを参考程度に同梱してあげる場合は、TypeScriptソースコードはts
ディレクトリに、コンパイルで生じたJavaScriptコードと型定義ファイルはjs
ディレクトリに分けて入れてあげよう。
├── index.ts
├── index.d.ts
├── index.js
└── package.json
├── ts
│ └── index.ts
├── js
│ ├── index.js
│ └── index.d.ts
└── package.json
ライブラリがこんな構成になっていませんか?
TypeScript製のライブラリをnpmで配布するとき、そのパッケージの構成は次のようなフラットな構造になっていませんか?フラットな構造とは、TypeScriptファイル(.ts)と、型定義ファイル(.d.ts)が同じディレクトリにあるような構成です。
├── index.ts ...... TypeScriptファイル
├── index.d.ts .... 上の型定義ファイル
| package.jsonのtypesフィールドで指定してる。
├── index.js ...... 上のJavaScriptファイル
| package.jsonのmainフィールドで指定している。
|
├── module.ts ..... TypeScriptファイル
| index.tsからimportされている。
├── module.d.ts ... 上の型定義ファイル
├── module.js ..... 上のJavaScriptファイル
|
└── package.json
TypeScriptファイルと同じディレクトリに型定義ファイルを生成するのは、TypeScriptコンパイラーのデフォルトの挙動です。そのため、こうした構成を取っているライブラリもあるかと思います。僕が配布しているライブラリがまさにこれでした。
一見問題なさそうなこの構成には、思わぬ落とし穴があります。
なぜライブラリにTypeScriptコードを同梱するのか?
どのような問題があるかを説明する前に、なぜライブラリにTypeScriptコードを含めたいのかについて話しておく必要があります。
ライブラリを提供する場合、基本的にJavaScriptファイルだけを同梱しておけば、動くものは提供できるのでそれで十分です。ライブラリの利用者がTypeScriptを使っている場合も想定すると、JavaScriptファイルに加えて型定義ファイル(.d.ts)も同梱してあげると、ライブラリ利用者は型情報も利用できるので、更に良いでしょう。
一方で、TypeScriptのソースコードは同梱してもしなくてもいいものです。それでも、これを同梱したくなるのは、ライブラリ利用者の開発体験を向上するためです。型定義ファイルしかないライブラリだと、ライブラリ利用者がエディターのコードジャンプなどでライブラリのシンボルにジャンプしたとき、型定義ファイルにジャンプするので、実装を知ろうと思ったら、対応するバージョンのソースコードをGitHubなどで調べないといけないといった手間が生じます。TypeScriptファイルの同梱されていると、コードジャンプでTypeScriptの実装をワンステップで見にいけるようになります。
このあたりの話については次の記事で詳しく扱っていますので御覧ください。
フラットな構造の落とし穴
話を戻しまして、フラットな構造のライブラリにはどのような問題があるのか見ていきます。
問題点を軽く要約すると、次のような内容になります。
- ライブラリのtsconfigがゆるめの設定
- ライブラリ利用者のtsconfigが厳しめの設定
このとき、
- ライブラリ利用者側で、ライブラリのコンパイルエラーが起きてしまう…
ライブラリ利用者側でコンパイルエラーになる具体例
この問題を理解するために、具体的なコードを見ていきましょう。
問題のあるライブラリrelaxed-mixed
relaxed-mixed
という架空のライブラリを例に見ていきます。このライブラリは、TypeScriptのコンパイル設定がゆるめになされています。
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true,
// チェックオプション
// 全部オフで非常にゆるい設定にしてあります。
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedIndexedAccess": false,
"noImplicitOverride": false,
"noPropertyAccessFromIndexSignature": false,
"forceConsistentCasingInFileNames": false
}
}
このように、strict
を始めとしたチェックオプションをすべて無効化してあります。
続いて、relaxed-mixed
ライブラリのindex.ts
のコードです。このコードには、unusedValue
という未使用の変数が定義されています。TypeScriptのコンパイラーオプションnoUnusedLocals
がtrue
であれば、これはコンパイルエラーになるところですが、relaxed-mixed
はゆるゆるなのでエラーになりません。
export { moduleValue } from './module'
const unusedValue = 0 // 使われていない変数。
export const value = 1
次に、module.ts
のコードです。これはindex.ts
から使われています。module.ts
にもunusedValue
がありますが、これもコンパイルエラーにはなりません。
const unusedValue = 0 // 使われていない変数。
export const moduleValue = 1
最後にこのライブラリのpackage.json
です。types
フィールドに型定義ファイルindex.d.ts
を指定してあります。
{
"name": "relaxed-separated",
"main": "index.js",
"types": "index.d.ts"
}
このライブラリをtsc
でエラーなくビルドでき、npmパッケージ化にするとその内容は次のようになります。
package/index.js
package/module.js
package/package.json
package/tsconfig.json
package/index.d.ts
package/index.ts
package/module.d.ts
package/module.ts
relaxed-mixed
の利用者に起きる問題
ここからは、relaxed-mixed
をインストールして使う利用者側に、どのような問題が起きるのか見ていきます。
relaxed-mixed
はnpmパッケージとしての体裁は整っているので、ライブラリのインストール(npm install
など)は問題なく行なえます。なので、このライブラリが次のようにnode_modules
に展開されたところから話を始めましょう。
node_modules/
└── relaxed-mixed
├── index.d.ts
├── index.js
├── index.ts
├── module.d.ts
├── module.js
├── module.ts
├── package.json
└── tsconfig.json
まず、利用者のTypeScriptコンパイル設定は厳格なものになっています。たとえば、struct
がtrue
だったり、未使用の変数をエラーとするnoUnusedLocals
もtrue
になっています。逆に、skipLibCheck
はtrue
がセットしてあり、ライブラリのd.ts
の検証は行わない設定になっています。
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
// ガチガチの静的チェックを行う設定にしてあります。
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
// d.tsの型チェックを行わないようにしています。
"skipLibCheck": true
}
}
ライブラリを利用するコードは次のmain.ts
です。relaxed-mixed
から2つの変数をインポートしているだけです。
// @ts-expect-error TS6192: All imports in import declaration are unused.
import {
moduleValue as moduleValue1,
value as value1,
} from "relaxed-mixed";
このコードをtsc
でコンパイルすると、次のようなエラーが発生します。
node_modules/relaxed-mixed/module.ts:1:7 - error TS6133: 'unusedValue' is declared but its value is never read.
1 const unusedValue = 0 // 使われていない変数。
~~~~~~~~~~~
Found 1 error in node_modules/relaxed-mixed/module.ts:1
このエラーは、relaxed-mixed
ライブラリのmodule.ts
で起きていて、unusedValue
が使われていないことについてのエラーです。ライブラリ側のtsconfigの設定はゆるめだったので、このエラーが起きませんでしたが、利用者側のtsconfigにはnoUnusedLocals
が設定されており、厳しめの設定になっているため、このエラーが起きてしまいます。
なぜmodule.d.ts
ではなく、module.ts
が使われたのか?
上のコンパイルエラーをよく見みると、relaxed-mixed
の型定義ファイルmodule.d.ts
ではなく、ソースコードのほうのmodule.ts
が用いられているのがわかります。
また、relaxed-mixed
のindex.ts
にも未使用の変数unusedValue
がありますが、これについてのエラーは報告されていません。index.ts
ではなくindex.d.ts
が使われているようです。
-
第1の疑問: なぜ
module.d.ts
ではなくmodule.ts
が使われたのか -
第2の疑問: なぜ逆に
index.ts
は使われず、index.d.ts
が使われたのか
どうしてこうなったのかはTypeScriptコンパイラーのモジュール解決の仕組みを知る必要があります。
TypeScriptのモジュール解決はいくつか戦略がありますが、今扱っている例ではライブラリ利用者のtsconfigでmoduleResolution
にnode
が設定されているので、Node.js風のモジュール解決戦略が取られます。
たとえば、
import {} from "relaxed-mixed";
のようなインポート文を書いたとき、TypeScriptコンパイラーは次のような順でnode_modules
内のファイルを探しに行きます。
- ./node_modules/relaxed-mixed.ts
- ./node_modules/relaxed-mixed.tsx
- ./node_modules/relaxed-mixed.d.ts
- ./node_modules/relaxed-mixed/package.json (typesプロパティで指定したファイル)
- ./node_modules/relaxed-mixed/index.ts
- ./node_modules/relaxed-mixed/index.tsx
- ./node_modules/relaxed-mixed/index.d.ts
relaxed-mixed
ライブラリにはpackage.json
があり、かつ、そこのtypes
プロパティに、index.d.ts
と書いてあるので、import "relaxed-mixed"
は、4番目の条件にヒットし、結果として./node_modules/relaxed-mixed/index.d.ts
を引き当てることになります。これが第2の疑問への答えです。TypeScriptコンパイラーはindex.d.ts
を直接見に行くので、index.ts
はコンパイル対象にならず、index.ts
にあるunusedValue
についてもエラーが報告されないわけです。
TypeScriptコンパイラーはindex.d.ts
を引き当てると、そこに書かれた./module
を探しに行きます。
export { moduleValue } from './module'; // これを探しに行く
export declare const value = 1;
./module
は次の順で探します。
- ./node_modules/relaxed-mixed/module.ts
- ./node_modules/relaxed-mixed/module.tsx
- ./node_modules/relaxed-mixed/module.d.ts
- ./node_modules/relaxed-mixed/module/package.json (typesプロパティで指定したファイル)
- ./node_modules/relaxed-mixed/module/index.ts
- ./node_modules/relaxed-mixed/module/index.tsx
- ./node_modules/relaxed-mixed/module/index.d.ts
relaxed-mixed
には該当するファイルとして、
- module.ts
- module.d.ts
の2つがありますが、.d.tsよりも.tsが優先して引き当てられるため、module.tsのほうが採用されます。これが第1の疑問に対する答えです。module.d.tsでなく、module.tsが引き当てられたために、未使用の変数unusedValue
についてのエラーが報告されたわけです。
解決策1: ライブラリのチェックを厳密にする
この問題を解決するひとつの方法として、ライブラリ側のチェックの設定を厳しくする方法が考えられます。たとえば、今回のケースではrelaxex-mixed
ライブラリのコンパイラーオプションnoUnusedLocals
をtrue
にすると解決するでしょう。なぜなら、ライブラリをリリースするときに、unusedVariable
の問題を直さないとリリースできないからです。
一般化して言うと、ライブラリのコンパイラーオプションが、利用者のコンパイラーオプションよりも、厳密である限り問題にならないわけです。
この方法でも解消できる問題はあるでしょうが、ライブラリが常に最も厳しい設定でありつづける必要があります。TypeScriptはアップデートで、新たなチェックオプションが加わったり、これまでより厳密なチェックを行うように改修されることがあります。そのような将来のことまで考えると、ライブラリは常に最新のTypeScriptを用いて、最新のコンパイラーオプションを使い、最も厳しいチェックをする必要があるわけです。
解決策2: TypeScriptのソースコードとコンパイルで生じるコードのディレクトリを分ける
ライブラリのチェックオプションをそこまで厳密にしたくない場合や、TypeScriptのアップデートに追従が難しい場合は、上の解決策では解消できません。
別の解決策として、TypeScriptのソースコードと、そこからコンパイルして生成されるJavaScriptや型定義ファイルといった成果物を別ディレクトリにする方法があります。
たとえば、次のようなディレクトリ構成にします。
├── ts ... TypeScriptソースコード
│ ├── index.ts
│ └── module.ts
├── js ... JavaScriptや型定義ファイルなどの成果物
│ ├── index.d.ts
│ ├── index.js
│ ├── module.d.ts
│ └── module.js
├── package.json
└── tsconfig.json
このディレクトリ構成では、ソースコードとなるTypeScriptファイルはts
ディレクトリに、JavaScriptファイルや型定義ファイルはjs
ディレクトリに配置され、完全に分離されています。
そしてpackage.json
を次のように設定します。
{
"name": "relaxed-separated",
"version": "1.0.0",
"main": "js/index.js",
"types": "js/index.d.ts"
}
main
フィールドとtypes
フィールドはどちらもjs
ディレクトリを見るようにします。このような設定にしておくことで、TypeScriptコンパイラーのモジュール解決で、ts
ディレクトリの中にあるTypeScriptファイルが使われる余地がなくなります。
最後にtsconfig.json
の設定は次のようにします。
{
"compilerOptions": {
// 生成物はjsディレクトリに置かれるようにする
"outDir": "js",
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"declaration": true,
// チェックオプション
// 全部オフで非常にゆるい設定にしてあります。
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"exactOptionalPropertyTypes": false,
"noImplicitReturns": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedIndexedAccess": false,
"noImplicitOverride": false,
"noPropertyAccessFromIndexSignature": false,
"forceConsistentCasingInFileNames": false
},
// コンパイル対象はtsディレクトリを見るようにする
"include": ["ts/**/*"]
}
tsconfigでは、まずoutDir
に"js"
ディレクトリを指定します。こうすることで、コンパイルして生成されるJavaScriptや型定義ファイルがjs
ディレクトリに置かれるようになります。
以上のようなディレクトリ構成と設定にしておくと、TypeScriptのモジュール解決にTypeScriptファイルが入ってくる余地がなくなります。たとえば、次のようなインポート文をライブラリ利用者が書いた場合は、
import { moduleValue } from "relaxed-separated";
次のようなモジュール解決が行われることになります。
"relaxed-separated"
に対するモジュール解決:
- ./node_modules/relaxed-separated.ts → ❌見つからない
- ./node_modules/relaxed-separated.tsx → ❌見つからない
- ./node_modules/relaxed-separated.d.ts → ❌見つからない
- ./node_modules/relaxed-separated/package.json
→ ✅ ./node_modules/relaxed-separated/js/index.d.tsが見つかる - ./node_modules/relaxed-separated/index.ts
- ./node_modules/relaxed-separated/index.tsx
- ./node_modules/relaxed-separated/index.d.ts
export { moduleValue } from './module';
// ..略
js/index.d.ts
における"./module"
のモジュール解決:
- ./node_modules/relaxed-separated/js/module.ts → ❌見つからない
- ./node_modules/relaxed-separated/js/module.tsx → ❌見つからない
- ./node_modules/relaxed-separated/js/module.d.ts → ✅ 見つかる
- ./node_modules/relaxed-separated/js/module/package.json
- ./node_modules/relaxed-separated/js/module/index.ts
- ./node_modules/relaxed-separated/js/module/index.tsx
- ./node_modules/relaxed-separated/js/module/index.d.ts
結論(再掲)
以上の検証を踏まえて、結論としては次のようになります。
ライブラリをnpmで公開する際に、TypeScriptソースコードを参考程度に同梱してあげる場合は、TypeScriptソースコードはts
ディレクトリに、コンパイルで生じたJavaScriptコードと型定義ファイルはjs
ディレクトリに分けて入れてあげよう。
├── index.ts
├── index.d.ts
├── index.js
└── package.json
├── ts
│ └── index.ts
├── js
│ ├── index.js
│ └── index.d.ts
└── package.json
このような構成にする理由:
- TypeScriptのモジュール解決は、.d.tsより.tsが優先される
- TypeScriptコンパイラーはライブラリでも.tsファイルが見つかるとコンパイルしようとする
- コンパイラーオプションは増える可能性があり、ライブラリよりライブラリ利用者のほうが厳しい設定になる可能性はなくはない