80
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-09-19

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

結論

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

❌Don't
├── index.ts
├── index.d.ts
├── index.js
└── package.json
✅Do
├── 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のコンパイル設定がゆるめになされています。

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のコンパイラーオプションnoUnusedLocalstrueであれば、これはコンパイルエラーになるところですが、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コンパイル設定は厳格なものになっています。たとえば、structtrueだったり、未使用の変数をエラーとするnoUnusedLocalstrueになっています。逆に、skipLibChecktrueがセットしてあり、ライブラリのd.tsの検証は行わない設定になっています。

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-mixedindex.tsにも未使用の変数unusedValueがありますが、これについてのエラーは報告されていません。index.tsではなくindex.d.tsが使われているようです。

  • 第1の疑問: なぜmodule.d.tsではなくmodule.tsが使われたのか
  • 第2の疑問: なぜ逆にindex.tsは使われず、index.d.tsが使われたのか

どうしてこうなったのかはTypeScriptコンパイラーのモジュール解決の仕組みを知る必要があります。

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

たとえば、

import {} from "relaxed-mixed";

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

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

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

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

  1. ./node_modules/relaxed-mixed/module.ts
  2. ./node_modules/relaxed-mixed/module.tsx
  3. ./node_modules/relaxed-mixed/module.d.ts
  4. ./node_modules/relaxed-mixed/module/package.json (typesプロパティで指定したファイル)
  5. ./node_modules/relaxed-mixed/module/index.ts
  6. ./node_modules/relaxed-mixed/module/index.tsx
  7. ./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ライブラリのコンパイラーオプションnoUnusedLocalstrueにすると解決するでしょう。なぜなら、ライブラリをリリースするときに、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の設定は次のようにします。

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"に対するモジュール解決:

  1. ./node_modules/relaxed-separated.ts → ❌見つからない
  2. ./node_modules/relaxed-separated.tsx → ❌見つからない
  3. ./node_modules/relaxed-separated.d.ts → ❌見つからない
  4. ./node_modules/relaxed-separated/package.json
    → ✅ ./node_modules/relaxed-separated/js/index.d.tsが見つかる
  5. ./node_modules/relaxed-separated/index.ts
  6. ./node_modules/relaxed-separated/index.tsx
  7. ./node_modules/relaxed-separated/index.d.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 → ❌見つからない
  2. ./node_modules/relaxed-separated/js/module.tsx → ❌見つからない
  3. ./node_modules/relaxed-separated/js/module.d.ts → ✅ 見つかる
  4. ./node_modules/relaxed-separated/js/module/package.json
  5. ./node_modules/relaxed-separated/js/module/index.ts
  6. ./node_modules/relaxed-separated/js/module/index.tsx
  7. ./node_modules/relaxed-separated/js/module/index.d.ts

結論(再掲)

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

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

❌Don't
├── index.ts
├── index.d.ts
├── index.js
└── package.json
✅Do
├── ts
│   └── index.ts
├── js
│   ├── index.js
│   └── index.d.ts
└── package.json

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
80
61

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?