🤩

ワークスペースにおける TypeScript パッケージの解決戦略

2024/11/14に公開

ワークスペースを利用している TypeScript プロジェクトの開発をしているとき、共通パッケージの依存解決の方法には

  • 素直に build 成果物を参照する
  • exports フィールドで build 前の TypeScript ファイルを直接参照する

等いくつかの選択肢があります。

このエントリでは、それぞれのやり方での制約や、開発体験の良し悪し等を比較して状況に応じてどういうアプローチを採用するのが良いか考えてみます。

補足資料

https://zenn.dev/kimuson/scraps/52da20e64f2d51

上記のスクラップで調べたことがベースになっています。

課題の整理

典型的な Full-Stack TypeScript なモノレポ構成を考えてみます:

my-project/
  ├── apps/
  │   ├── frontend/      # フロントエンド
  │   ├── backend/       # バックエンド
  ├── packages/
  │   └── shared/        # 共通パッケージ
  ├── package.json
  └── turbo.json
  • frontend は一旦はシンプルに考えたいので Vite を利用した SPA が入る想定です
  • backend には Node.js で実行するバックエンドアプリケーション(FW は何でも良いですが、honoexpress と言った標準的なものを想定しながら書いています)
  • モノレポ管理ツールはシンプルなのと筆者が慣れているので Turborepo を使いますが、他でも問題ないです

この構成で、以下のような要件を満たすことが望ましいです:

  1. frontend と backend の両方から共通パッケージ(shared)を参照できる
  2. 開発時は共通パッケージ(shared)のコード変更が frontend/backend に即時に反映される
  3. 開発時に frontend から shared のソースコード(build 成果物ではない)にジャンプできる

アプローチ 1: モノレポ管理ツールを使った開発サーバーの並列起動

最も素直なアプローチとして、モノレポ管理ツールの機能などを利用して全パッケージの開発サーバーを起動する方法があります。

turbo.json
{
  "pipeline": {
    "dev": {
      "cache": false,
      "persistent": true,
      "dependsOn": ["^dev"]
    }
  }
}
shared/package.json
{
  "scripts": {
    "dev": "tsup --watch --dts --format esm"
  }
}

shared のソースコードに変更が入った場合は tsup [1] がトランスパイルして型を書き出すことで build 結果も更新されるので frontend/backend もホットリロードできます。

メリット

  • 設定がシンプル
  • workspace 機能で npm に公開しても動くパッケージをそのまま node_modules に配っているだけなので、考えることは少ない

デメリット

  • 複数の開発サーバーが起動するため、システムリソースを多く消費
  • 依存するパッケージで Hot Reload された変更の反映が不安定になる
  • 型定義からジャンプした際にソースコードではなく書き出された .d.ts ファイルに飛んでしまう

アプローチ 2: exports フィールドから TypeScript ファイルを直接参照させる

2 つ目は package.json の exports フィールドに直接 TypeScript ソースコードを指定して参照させる方法です。

shared/package.json
{
  "name": "@my-project/shared",
  "type": "module",
  "exports": "./src/index.ts"
}

これで tsc やバンドラがモジュール解決する際には TypeScript ソースコードが参照され、shared 側で変更時に dist を更新せずとも変更が反映されますし、エディタがジャンプする先もソースコードになります。

このやり方をする場合の注意点として、shared/src 以下のソースコードが shared/tsconfig.json だけでなく複数の TypeScript の設定(apps/frontend/tsconfig.json, apps/backend/tsconfig.json) から解決される点に注意する必要があります。

例えば moduleResolution が backend(Node16) と shared(Bundler) で異なる場合、shared で解釈できても backend では import 文に .js 拡張子がないから参照できない、といったことが起こります。

また、import hoge from '@/hoge' のように書くために baseURL と paths を利用した alias 設定についても app 側の tsconfig で読むときは依存解決できなくなります。

したがって、この方針を取る場合は全てのパッケージで tsconfig.json のモジュール解決に関するオプションを以下のように統一しておくと良さそうです:

tsconfig.json
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler" // FE は Bundler 以外選択肢がなく、BE は Bundler を介して実行できるため
    // baseURL, paths は使用しない
  }
}

メリット

  • 設定がシンプル
  • 開発サーバーが 1 つで済むため、ローカルマシンへの負荷が少ない
  • Hot Reload を連鎖せず直接参照しているので Hot Reload の動きが安定しやすい
  • 定義ジャンプ先がソースコードになる

デメリット

  • Node.js 環境(バックエンド、BFF、SSR)での実行が大変
    • app 側で本番向けに実行する際にバンドルなしで依存解決することはできません
      • ランタイムで shared パッケージを読み込む必要がありますが、import に TypeScript ファイルが指定されていると当然解決できないので
    • となるとビルド時にバンドルすることで依存解決しておくしかないですが、Node.js 向けの dependencies を含めたバンドルは __dirname やビルドインモジュール、ネイティブバリナリ等で問題を踏みがちなのでできれば避けたいです [2]
    • shared 等のワークスペース内のパッケージのみバンドルすればこれらの問題を一定回避できますが、設定は煩雑になります
      • 例えば bundle を有効にした上で依存するパッケージ名を羅列し、shared 以外のすべてのパッケージ名を external に指定するような処理を書く必要が出てくるでしょう
  • パッケージを公開しづらい
    • shared のパッケージを npm や GitHub Packages 等の社内レジストリに公開したくなることもあると思います
    • import や require に build された成果物ではなく TypeScript ファイルが指定されているパッケージを公開することになります。利用側のツールチェーンの設定によってはバンドルして問題なく実行できるかもしれませんが、基本的には望ましくないと思います。
  • 複数のパッケージで同じバージョンの依存を使う必要性が高くなる
    • モジュール解決オプション同様に tsc のバージョンや共通の依存(例えばデザインシステムなら react の型定義等)を統一しておかないと、アプリ側の型チェックでのみエラーになると言ったことが起こりえます
    • workspace を利用する時点で一定パッケージのバージョンを揃えたくなりますが、この方針を取る場合はより統一の必要性が高くなります。syncpack 等パッケージ間のバージョン不一致を防ぐツールが必要になるかもしれません。

アプローチ 2 は手軽で、パッケージの公開や Node.js 環境での実行を考慮しない場合は良い選択肢になりそうです。

アプローチ 3: Conditional exports にカスタムの値を利用してソースコード/成果物のどちらに解決するかを制御する

アプローチ 3 ではアプローチ 2 のやり方を発展させて、カスタムな Conditional exports [3] を利用することで

  • Node.js 環境(バックエンド、BFF、SSR)での実行には課題がある
  • パッケージを公開しづらい

の問題を解消したアプローチを紹介します。

Node.js やバンドラーがサポートする標準の condition(import, require, ...etc)に加えて、独自の condition を解決させるオプションが各ツールチェーンでサポートされており、こちらを利用します。

shared の exports フィールドに以下のように設定を書きます。

shared/package.json
{
  "name": "@my-project/shared",
  "exports": {
    ".": {
      "@my-project/src": "./src/index.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}

独自の condition として @my-project/src を追加して先頭に置きました。

これで

  • 素直に参照したときは import, require で成果物が参照される
  • ツールに意図的に設定をして @my-project/src を解決できる状態で読めば TypeScript ファイルが解決される

という状態にできました。

frontend での利用

まずは Vite でシンプルな SPA として参照する場合を考えてみます。

Vite と tsconfig で custom condition の設定をします。

vite.config.ts
export default defineConfig({
  resolve: {
    conditions: ['@my-project/src'],
  },
})
tsconfig.json
{
  "compilerOptions": {
    "customConditions": ["@my-project/src"]
  }
}

以上の設定で

  • Vite が shared の build 成果物ではなくソースコードを参照する
  • tsc も同じくソースコードを参照するので、エディタの型チェックや定義ジャンプもソースコードに向く

ようになりました。

本番向けも結局バンドルすることになるので、Vite でバンドルする際は本番向けビルド時に TS ファイルが読み込まれる動きでも問題ないかなと思います。[4]

backend での利用

frontend 同様に customConditions を tsconfig で設定しておき、期待するエディタサポートは受けられるようにしておきます。

tsconfig.json
{
  "compilerOptions": {
    "customConditions": ["@my-project/src"]
  }
}

backend アプリは Node.js で実行されることを想定しています。

開発時は、tsx を利用するとして

$ tsx -C '@my-project/src' src/index.ts

のように開発サーバーを起動できます。
custom condition で shared からはビルド成果物ではなくソースコードを読み込んで実行する形になります。

本番向けのビルド・実行方法ですが

  • moduleResolution を Bundler に統一しているので少なくとも src 以下はバンドルしないとランタイムではモジュール解決できない
  • アプローチ 2 のデメリットでも書いたように全パッケージのバンドルはあまりやりたくない

という制約があるので、以下のように行うのが良いと思います。

# build
# `--packages=external` を指定することで「dependencies はバンドルしないが、ソースコードはバンドルする」
esbuild ./src/index.ts --format=esm --platform=node --bundle --packages=external --outdir=dist

# 実行
# shared 等の内部ライブラリの build 成果物は workspace の機能で node_modules に配られている想定
node ./dist/index.js

メリット

  • 開発時/本番時など、柔軟に参照先を成果物にするか/ソースコードにするか切り替えできる
  • custom condition によって必要な場合にオプトインする形なので、exports.{require,import} を汚さずにそのまま公開できる
  • Node.js 環境でも煩雑な設定をせずに実行できる

デメリット

  • condition 周りの設定がやや複雑になる
  • ツールチェーンごとの対応が必要

Next.js の場合 (静的ビルド)

frontend アプリケーションが Next.js の場合も考えてみます。

まず Next.js での custom condition の設定ですが、オプションを提供されていないので以下のように webpack plugin を使って設定する必要があります。

next.config.mjs
class NextEntryPlugin {
  constructor(name) {
    this.name = name
  }

  apply(compiler) {
    compiler.hooks.afterEnvironment.tap('NextEntryPlugin', () => {
      compiler.options.resolve.conditionNames = [
        this.name,
        ...compiler.options.resolve.conditionNames,
      ]
    })
  }
}

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: config => ({
    ...config,
    plugins: [...config.plugins, new NextEntryPlugin('@my-project/src')],
  }),
}

export default nextConfig

Static Exports (output: 'export') なアプリケーションなら Vite と同様に設定すれば良いでしょう。実行環境はブラウザだけなので、他にバックエンドアプリや公開する予定のパッケージがなければアプローチ 2 でも問題ないです。

Next.js の場合(SSR や API のために Node.js が必要)

SSR や API の機能を利用する場合は Node.js で実行することになりますが、ビルドやバンドルは Next.js が行うのでどういう動きをしているか次第で対応が変わります。

Next.js の公式ドキュメントには以下の記載があります:

https://nextjs.org/docs/app/api-reference/next-config-js/serverExternalPackages

Dependencies used inside Server Components and Route Handlers will automatically be bundled by Next.js.

サーバーコンポーネントや API の依存は Next.js が自動的にバンドルしているようです。

したかって Next.js 経由で Node.js 上で実行する場合は

  • 依存解決は build 時に Next.js が行っているので、Node.js 上の実行ではありますが別途バンドル云々の話は気にしなくて良い
  • shared パッケージで一度バンドルしても良いが、Next.js によって最終的にバンドルされ node_modules がない状態で動作するのでビルドステップを複雑にする必要性は薄そう
  • 他にバックエンドアプリや公開する予定のパッケージがなければアプローチ 2 で Custom な Condition がなくても問題ない
    • アプローチ2なら exports に TS ファイルを指定して終わり
    • アプローチ3なら exports の Custom Condition に TS ファイルを指定して、利用側で Webpack Plugin の追加と tsconfig#customConditions の指定をすればOK

ということになります。

終わりに

モノレポ環境におけるパッケージの参照方法に関する複数のアプローチについて紹介しました!

まとめると

  • 全パッケージウォッチを建てる方法もできるが、開発体験の低下につながるので TS ファイルを直接参照させる方法がオススメ
  • TS ファイルを直接参照させる場合、任意の .ts ファイルを各パッケージの tsconfig で解釈することになるので、モジュール解決関連のオプションを統一し、依存する TypeScript 本体や外部パッケージのバージョンを統一しておくのが良い
  • 共通のパッケージを社内でも公開することがないかつ、ブラウザではなく Node.js 環境で実行する app[5] がない場合は exports.{import, require} 等の標準の condition に直接 TypeScript ファイルを書いてしまうのが手軽
  • 公開することがある場合や Node.js で実行することがある場合は、TypeScript ファイルで解決するか build 成果物で解決するかを選択したいので、Custom Condition を利用すると良い

という内容でした。
モノレポ内でのパッケージ解決を考える際に参考になれば幸いです!

脚注
  1. tsup は単体で dual package 対応、esbuild ベースのバンドル、dts の書き出し等を行うことができるツールです。簡単なので tsup で書いてますが、他のツールでも同様にできます。例えば esbuild でトランスパイルして tsc で型定義を書き出すならそれぞれ watch server を建てらればそれで良いです。 ↩︎

  2. shared 自体は FE/BE 共通で利用されるパッケージなのでフルバンドルでも問題ない可能性が高いですが、他の Node.js 依存のパッケージで問題が発生する可能性が高いです。 ↩︎

  3. Conditional exports は exports フィールドに require, import, ...etc 等の条件ごとのファイルパスを指定することで、それぞれの条件(例. ESModules, CommonJS なのか等)で解決しようとしたときにどのパスに解決させるかを分岐させる機能です。 ↩︎

  4. shared をちゃんとビルドしたものを読み込みたい場合は、NODE_ENV 等を見て build 時には conditions の @my-project/src を外してビルドするようにすれば良いはずです。 ↩︎

  5. 記事中にも記載のある通り Next.js を利用して SSR している場合はバンドルされるので含みません。 ↩︎

株式会社エス・エム・エス

Discussion