ライブラリにTypeScriptコードを同梱するときはディレクトリを分けよう

この投稿では、ライブラリをnpmで公開する際に、TypeScriptソースコードを同梱する場合、どのようなディレクトリ構成が良いかを説明するものです。ライブラリ開発者の皆様にとって、役立つ情報になればいいなと思います。

## 結論

ライブラリをnpmで公開する際に、TypeScriptソースコードを参考程度に同梱してあげる場合は、TypeScriptソースコードは`ts`ディレクトリに、コンパイルで生じたJavaScriptコードと型定義ファイルは`js`ディレクトリに分けて入れてあげよう。

```text:❌Don't
├── index.ts
├── index.d.ts
├── index.js
└── package.json
```

```text:✅Do
├── ts
│   └── index.ts
├── js
│   ├── index.js
│   └── index.d.ts
└── package.json
```



## ライブラリがこんな構成になっていませんか？

TypeScript製のライブラリをnpmで配布するとき、そのパッケージの構成は次のような**フラットな構造**になっていませんか？フラットな構造とは、TypeScriptファイル(.ts)と、型定義ファイル(.d.ts)が同じディレクトリにあるような構成です。

```text:フラットな構造
├── 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の実装をワンステップで見にいけるようになります。

このあたりの話については次の記事で詳しく扱っていますので御覧ください。

https://zenn.dev/suin/scraps/9f454023cefb67

## フラットな構造の落とし穴

話を戻しまして、フラットな構造のライブラリにはどのような問題があるのか見ていきます。

問題点を軽く要約すると、次のような内容になります。

- ライブラリのtsconfigがゆるめの設定
- ライブラリ利用者のtsconfigが厳しめの設定

このとき、

- ライブラリ利用者側で、**ライブラリのコンパイルエラーが起きてしまう**…

### ライブラリ利用者側でコンパイルエラーになる具体例

この問題を理解するために、具体的なコードを見ていきましょう。

#### 問題のあるライブラリ`relaxed-mixed`

`relaxed-mixed`という架空のライブラリを例に見ていきます。このライブラリは、TypeScriptのコンパイル設定がゆるめになされています。

```js:tsconfig.json
{
  "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`はゆるゆるなのでエラーになりません。

```index.ts
export { moduleValue } from './module'

const unusedValue = 0 // 使われていない変数。
export const value = 1
```

次に、`module.ts`のコードです。これは`index.ts`から使われています。`module.ts`にも`unusedValue`がありますが、これもコンパイルエラーにはなりません。

```module.ts
const unusedValue = 0 // 使われていない変数。
export const moduleValue = 1
```

最後にこのライブラリの`package.json`です。`types`フィールドに型定義ファイル`index.d.ts`を指定してあります。

```package.json
{
  "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`の検証は行わない設定になっています。

```js:tsconfig.json
{
  "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つの変数をインポートしているだけです。

```main.ts
// @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コンパイラーのモジュール解決の仕組みを知る必要があります。

https://www.typescriptlang.org/docs/handbook/module-resolution.html

TypeScriptのモジュール解決はいくつか戦略がありますが、今扱っている例ではライブラリ利用者のtsconfigで`moduleResolution`に`node`が設定されているので、Node.js風のモジュール解決戦略が取られます。

たとえば、

```ts
import {} from "relaxed-mixed";
```

のようなインポート文を書いたとき、TypeScriptコンパイラーは次のような順で`node_modules`内のファイルを探しに行きます。

1. ./node_modules/**relaxed-mixed**.ts
1. ./node_modules/**relaxed-mixed**.tsx
1. ./node_modules/**relaxed-mixed**.d.ts
1. <u>./node_modules/**relaxed-mixed**/package.json</u> (typesプロパティで指定したファイル)
1. ./node_modules/**relaxed-mixed**/index.ts
1. ./node_modules/**relaxed-mixed**/index.tsx
1. ./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`を探しに行きます。

```ts:index.d.ts
export { moduleValue } from './module'; // これを探しに行く
export declare const value = 1;
```

`./module`は次の順で探します。

1. <u>./node_modules/relaxed-mixed/**module**.ts</u>
1. ./node_modules/relaxed-mixed/**module**.tsx
1. ./node_modules/relaxed-mixed/**module**.d.ts
1. ./node_modules/relaxed-mixed/**module**/package.json (typesプロパティで指定したファイル)
1. ./node_modules/relaxed-mixed/**module**/index.ts
1. ./node_modules/relaxed-mixed/**module**/index.tsx
1. ./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`を次のように設定します。

```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`の設定は次のようにします。

```js: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ファイルが入ってくる余地がなくなります。たとえば、次のようなインポート文をライブラリ利用者が書いた場合は、

```ts
import { moduleValue } from "relaxed-separated";
```

次のようなモジュール解決が行われることになります。

`"relaxed-separated"`に対するモジュール解決:

1. ./node_modules/**relaxed-separated**.ts → ❌見つからない
1. ./node_modules/**relaxed-separated**.tsx → ❌見つからない
1. ./node_modules/**relaxed-separated**.d.ts → ❌見つからない
1. <u>./node_modules/**relaxed-separated**/package.json</u><br> → ✅ ./node_modules/relaxed-separated/js/index.d.tsが見つかる
1. ./node_modules/**relaxed-separated**/index.ts
1. ./node_modules/**relaxed-separated**/index.tsx
1. ./node_modules/**relaxed-separated**/index.d.ts

```ts:./node_modules/relaxed-separated/js/index.d.ts
export { moduleValue } from './module';
// ..略
```

`js/index.d.ts`における`"./module"`のモジュール解決:

1. ./node_modules/relaxed-separated/js/**module**.ts → ❌見つからない
1. ./node_modules/relaxed-separated/js/**module**.tsx → ❌見つからない
1. <u>./node_modules/relaxed-separated/js/**module**.d.ts</u> → ✅ 見つかる
1. ./node_modules/relaxed-separated/js/**module**/package.json
1. ./node_modules/relaxed-separated/js/**module**/index.ts
1. ./node_modules/relaxed-separated/js/**module**/index.tsx
1. ./node_modules/relaxed-separated/js/**module**/index.d.ts

## 結論(再掲)

以上の検証を踏まえて、結論としては次のようになります。

ライブラリをnpmで公開する際に、TypeScriptソースコードを参考程度に同梱してあげる場合は、TypeScriptソースコードは`ts`ディレクトリに、コンパイルで生じたJavaScriptコードと型定義ファイルは`js`ディレクトリに分けて入れてあげよう。

```text:❌Don't
├── index.ts
├── index.d.ts
├── index.js
└── package.json
```

```text:✅Do
├── ts
│   └── index.ts
├── js
│   ├── index.js
│   └── index.d.ts
└── package.json
```

このような構成にする理由:

- TypeScriptのモジュール解決は、.d.tsより.tsが優先される
- TypeScriptコンパイラーはライブラリでも.tsファイルが見つかるとコンパイルしようとする
- コンパイラーオプションは増える可能性があり、ライブラリよりライブラリ利用者のほうが厳しい設定になる可能性はなくはない