🔧

TypeScript のプロジェクト分割だけでは辛いので npm workspaces を使用する

2025/01/11に公開

はじめに

過去のコードが TypeScript の ProjectReferences により分割されていました。
分割自体はいいのですが、数が多く管理の手間が多いので、 npm の workspaces でまずは管理できるようにしようと思いました。

TypeScript の ProjectReferences とは

https://www.typescriptlang.org/docs/handbook/project-references.html

tsconfig.json
{
    "compilerOptions": {
        // The usual
    },
    "references": [
        { "path": "../src" }
    ]
}

参照したいプロジェクトの tsconfig.json へのパスを指定します。{ "path": "../src/tsconfig.build.json" } のように違う tsconfig の指定も可能です。
これにより参照先の outDir で指定した出力先の .d.ts を読み込み、プロジェクトで利用できます。
Project References の設定を有効にする場合は、 compilerOption の composite と declarationMap を true にする必要があります。
本設定により参照先が未ビルドの際にビルドしたり、エディターで定義元への参照リンクがつながるようになります。
composite を有効にすると declarationMap だけでなく、 incremental のデフォルト値も有効になります。これにより差分ビルドを行うため、必要なプロジェクトのみのビルドになります。
.tsbuildinfo というファイルがデフォルトでは outDir 直下に出力されます。

tsc の -b-p の違いについて

-b or --build オプションは参照先のビルドも自動的に行います。

 $ tsc -b                            # Use the tsconfig.json in the current directory
 $ tsc -b src                        # Use src/tsconfig.json
 $ tsc -b foo/prd.tsconfig.json bar  # Use foo/prd.tsconfig.json and bar/tsconfig.json

-p or --project オプションは自身のプロジェクトのコンパイルのみとなるので、参照プロジェクトがある場合はあらかじめビルドが必要です。

$ tsc -p foo/tsconfig.json

npm の workspaces とは

https://docs.npmjs.com/cli/v11/using-npm/workspaces
workspaces はプロジェクトルート直下にあるマルチプロジェクトを扱うための機能です。
$ npm init -w packages/a を実行すると package.json の workspaces に追加されます。

package.json
{
  "name": "my-workspaces-powered-project",
  "workspaces": ["packages/a"]
}

ルート直下に packages/a/package.json が作られます。
さらに npm install をすることで、ルート直下の node_modules にシンボリックリンクが作られます。

.
├──  node_modules
|  └──  a -> ../packages/a // npm install によりシンボリックリンクが作られる
├──  package-lock.json
├──  package.json
└──  packages
   └──  a
       └── package.json

これにより $ npm run test -w packages/a と指定するだけで対象のパッケージのコマンドを実行できます。

workspace に対するコマンド実行

オプション指定の前提

-workspace or -w は workspace を単体で実行する。

$ npm run test -workspace packages/app
$ npm run test -w packages/app -w packages/core

-workspaces or -ws は workspace を全てにおいて実行する。

$ npm run test -workspaces
$ npm run test -ws

--if-present でコマンドが存在する workspace でのみ実行できる。

$ npm run test --workspaces --if-present

npm install

$ npm install xxx -w packages/app
# package も指定が可能
$ npm install project_name/core -w packages/app

workspace の package は node_modules にシンボリックリンクを作る

./node_modules
├── project_name
│         ├── app -> ../../packages/app
│         └── core -> ../../packages/core
└── typescript

NG設定例 : tsconfig の paths を利用する

フォルダ構成は以下とします。

./packages
├── app
│   ├── dist
│   └── src
└── core
    ├── dist
    └── src
tsconfig.json
{
  "paths": {
    "@core/*": ["../../node_modules/package_name/dist/*"]
    // または、直接指定する
    // "@core/*": ["../../packages/core/dist/*"]
   },
}

元々 workspaces を使わず、references で指定したプロジェクトに対して paths を設定していました。 import 文の記述量を減らし、わかりやすさを担保するためです。
しかし、そもそも node_modules にパッケージとして入るため、 package_name を指定してインポートができるため、設定自体が不要になるはずです。

tsconfig の paths とは

https://www.typescriptlang.org/tsconfig/#paths

import 時のパス解決に対してマッピングを定義できます。 これを指定することで相対パスによる記述をしなくて良くなります。

tsconfig.json
{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "paths": {
      "@app/*": ["./src/*"]
    }
  }
}

注意点としては、TypeScript のコンパイルとしては指定したパスで出力します。 そのため、Bundler 側にもパス解決のための設定が必要です。

tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "paths": {
      "node-has-no-idea-what-this-is": ["./oops.ts"]
    }
  }
}

バンドルの設定がない場合、以下は TypeScript としてはエラーにはなりませんが、実行時エラーになります。

// TypeScript: ✅
// Node.js: 💥
import {} from "node-has-no-idea-what-this-is";

paths には node_modules を指定してはいけない

https://www.typescriptlang.org/docs/handbook/modules/reference.html#paths-should-not-point-to-monorepo-packages-or-node_modules-packages
package.json の exports をサポートしており、paths とのバッティングは実行時に期待通りの動作をしない可能性があります。

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "pkg": ["./node_modules/pkg/dist/index.d.ts"],
      "pkg/*": ["./node_modules/pkg/*"]
    }
  }
}

package.json と tsconfig.json の設定例

フォルダ構成は以下とします。

./packages
├── app
│   ├── dist
│   └── src
└── core
    ├── dist
    └── src

packages/core の設定は以下とします。

packages/core/package.json
{
  "name": "project_name/core",
  "version": "1.0.0",
  "exports": {
    "./*": "./dist/*.js"
  },
  "scripts": {
    "build": "tsc -b",
  },
}
packages/core/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "target": "es2016",
    "module": "NodeNext",
    "declaration": true,
    "outDir": "./dist",
    "tsBuildInfoFile": "./dist/.tsbuildinfo",
    "rootDir": "./src",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "NodeNext"
  },
  "include": ["./src/**/*.ts"]
}

packages/app の設定は以下とします。

packages/app/package.json
{
  "name": "project_name/app",
  "scripts": {
    "build": "tsc -b",
  },
}

モノレポとしての構成として考えているので、 references の指定は行います。 TypeScript に依存関係があることを明示し、IDE で参照先を解決するためです。

packages/app/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "target": "es2016",
    "module": "NodeNext",
    "declaration": true,
    "outDir": "./dist",
    "tsBuildInfoFile": "./dist/.tsbuildinfo",
    "rootDir": "./src",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "NodeNext"
  },
  "references": [{
    "path": "../core"
  }],
  "include": ["./src/**/*.ts"]
}

今回、package.json では exports の設定をしました。 package.json の exports の設定を TypeScript 側でも正しく読み込むためには module / moduleResolution に NodeNext の指定が必要です。

パッケージのエントリーポイント とは

main

https://docs.npmjs.com/cli/v11/configuring-npm/package-json#main

package.json
{
  "main" : "./src/index.js"
}

import * from "project_name" という指定で index.js のモジュールを単一モジュールとして読み込みます。

exports

https://docs.npmjs.com/cli/v11/configuring-npm/package-json#exports
https://nodejs.org/api/packages.html#package-entry-points

以下のようなファイルが作成されているとします。

packages/core/src
├── index.js
└── other.js

例えば、ワイルドカードで指定できます。

core/package.json
{
  "exports": {
    "./*": "./src/*.js"
  },
}

複数のエントリーポイントを指定しているので、それぞれインポートが可能になります。

import * from "package_name/core/index" // OK
import * from "package_name/core/test" // OK

複数のエントリーポイントだけでなく、 CommonJS や ESM 向けにファイル指定も可能です。

package.json
// package.json
{
  "exports": {
    "import": "./index-module.js",
    "require": "./index-require.cjs",
    "node": "xxx.js" // node で実行する
    "default": "xxx.js"
  },
  "type": "module"
}

module と moduleResolution の指定

https://www.typescriptlang.org/docs/handbook/modules/reference.html#packagejson-exports

moduleResolution の設定を node16nodenext、または bundler にすることで package.json の exports の仕様に沿います。
上記の設定をするとデフォルト値として resolvePackageJsonExports も true に設定されます。
さらに、 moduleResolution で nodenext を使用すると、 module でも nodenext を指定しなければなりません。

パッケージを module として扱うにあたり、拡張子によって異なる解釈が行われます。

  • .mts / .mjs / .d.mts
    • ES modules として扱われる。つまり、 import / export を使用する。
  • .cts / .cjs / .d.cts
    • CommonJS modules として扱われる。つまり、 require / module.exports を使用する。
  • .ts / .tsx / .js / .jsx / .d.ts
    • package.json に "type": "module" を指定している場合は ES modules、それ以外は CommonJS modules として扱われる。

node16nodenext の指定は CommonJS または ESM 形式のどちらかで出力します。検出されたファイルのモジュールの指定によって決まります。

おわりに

一旦、ここまで移行した後に、今度は Lerna とかも導入できないか考えたいと思います。

https://lerna.js.org/

Discussion