Open27

モノレポで依存パッケージの型解決に使えそうな tsconfig の customConditions を試す

きむそんきむそん

customConditions がモノレポで依存パッケージの型解決に良さげなので見る

https://www.typescriptlang.org/ja/tsconfig/#customConditions

きむそんきむそん

TS5.0 から追加されていたらしい

exports フィールドに import, require, types 等標準の key 以外を指定したものを解決できるようにするオプション

package.json
{
  // ...
  "exports": {
    ".": {
      "my-condition": "./src/bar.ts",
      "node": "./dist/bar.cjs",
      "import": "./dist/bar.mjs",
      "require": "./dist/bar.cjs",
      "types": "./dist/bar.d.ts"
    }
  }
}

のように指定されているときに

tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "moduleResolution": "bundler",
    "customConditions": ["my-condition"]
  }
}

のように指定する

きむそんきむそん

tsc を介して読むときは my-condition で解決されるので、参照が直接 ./src/bar.ts に向く

  • 例えば VSCode で定義ジャンプするときに直接 ts ファイルに飛べる
  • bar.ts の型で型チェックが行われる

一方、node やバンドラー側でモジュール解決するときには my-condition は無視されるので、import, require 等のフィールドで解決される、という動きになるっぽい

きむそんきむそん

Next.js で試す

きむそんきむそん

helpers モジュールを用意する

package.json
{
  "name": "helpers",
  "type": "module",
  "exports": {
    ".": {
      "monorepo-custom-conditions/dev": "./src/index.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}

condition に関係ないパッケージで dev とかが指定されていると、そのパッケージを誤解決してしまう可能性があるので汎用的な名前は避けるのが良さそう。今回は monorepo-custom-conditions/dev を指定する。

tsconfig.json
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "module": "ESNext"
  },
  "include": ["src"]
}

ビルドは割愛するが dual package 対応したものが吐き出される

➜ tree dist
dist
├──  index.cjs
├──  index.d.cts
├──  index.d.ts
└──  index.js

➜ tree src 
src
├──  functions
│   ├──  assert-min-length.test.ts
│   ├──  assert-min-length.ts
│   ├──  errors.ts
│   ├──  typed-object-keys.test.ts
│   ├──  typed-object-keys.ts
│   ├──  typed-range.test.ts
│   └──  typed-range.ts
├──  index.ts
└──  types.ts
きむそんきむそん

Next.js のアプリ側にパッケージと customConditions の設定を追加する

package.json
{
  "name": "frontend",
  "scripts": {
    "dev": "next dev --turbo",
    "build": "next build",
    "typecheck": "tsc -p ./tsconfig.json --noEmit"
  },
  "devDependencies": {
    "helpers": "workspace:*"
  }
}

※書いてなかったけど pnpm workspace を使っている

tsconfig.json
{
  "compilerOptions": {
    // ...
    "module": "esnext",
    "moduleResolution": "bundler",
    // ...
    "customConditions": ["monorepo-custom-conditions/dev"]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
きむそんきむそん

定義ジャンプしたら d.ts ではなく ts ファイルにちゃんと飛んでくれている :+1:

きむそんきむそん

customConditions で参照することで依存パッケージの watch を建てずに開発できてほしいので、helpers を build していない状態で開発できるか試す

cd packages/helpers
rm -rf dist
cd ../../apps/frontend
pnpm dev

エディタで型定義は参照できたけど

参照するとエラーになる。

"type": "module" なので import の condition が採用されて、バンドラは dist を見に行くのでエラーになる。それはそう。

きむそんきむそん

公式のサポートはなさそうだけど、ワークアラウンドを見つけた

https://github.com/vercel/next.js/discussions/33813

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("monorepo-custom-conditions/dev"),
    ],
  }),
}

export default nextConfig

上記の webpack プラグインを指すことで

バンドラが monorepo-custom-conditions/dev を解決できるようになった。
これで helpers/src が変更されるたびに dist が更新される仕組みを作らなくても、ts ファイルを直接参照して開発を行うことができる

きむそんきむそん

helpers の方で path alias を使うと解決できるのか?

相対パス指定が嫌なときによく使われる

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },
  }
}

があると動かなそうだけど解決できる?

helpers 内で相対パスで参照していた解決を一部 path alias を使って解決してみる

ソースコードを直接見ているときは当然解決できて型エラーも特に起きない

利用している側からは参照できなくなっていた

バンドラも解決できていない

ということで、少なくとも簡単にはできなそう。

この方針でやる場合には、他のパッケージから利用されるパッケージは path alias を利用しないべき

きむそんきむそん

あと影響ありそうなのが、モジュール解決周りのオプションは利用する側とパッケージ側で統一できていないと潜在的な問題がありそう。

わかりやすいのは moduleResolution の Node16Bundler. 前者は hoge.js の形でインポートを要求してくるが後者で読むときは拡張子があると読めない

helpers の moduleResolution を Node16 にしてみる

.js がないので型エラーが出るようになる。.js をつけて回る。

無理かと思ったら解決できちゃった。あれ、Bundler のときって .js が付いていても解決できるんだっけ?

→そうだった。

となると逆パターンは無理なはずなので、node で実行するパターンを試すときに一緒に試してみる

きむそんきむそん

node で実行する場合

きむそんきむそん

パッケージを追加

package.json
{
  "name": "backend",
  "scripts": {
    "dev": "pnpm tsx --tsconfig ./tsconfig.json ./src/main.ts",
    "build": "tsc -p ./tsconfig.json",
    "typecheck": "tsc -p ./tsconfig.json --noEmit"
  },
  "devDependencies": {
    "tsx": "^4.19.1",
    "helpers": "workspace:*"
  },
  "type": "module"
}
きむそんきむそん

tsc でビルドして node で実行する想定で tsconfig を追加してみる

tsconfig.json
{
  "compilerOptions": {
    "noEmit": false,
    "outDir": "dist",
    "moduleResolution": "Node16",
    "module": "Node16",
    "customConditions": ["monorepo-custom-conditions/dev"]
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}
きむそんきむそん

とりあえず build してみると、helpers が Bundler のオプションで .js なしで書かれているので型エラーが出てしまう

きむそんきむそん

module 解決のオプションを統一する

tsconfig を書き換えて Bundler にしてみる

tsconfig.json
{
  "compilerOptions": {
    "noEmit": false,
    "outDir": "dist",
    "moduleResolution": "Bundler",
    "module": "ESNext",
    "customConditions": ["monorepo-custom-conditions/dev"]
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}
pnpm build

> backend@0.0.0 build /Users/kaito/Playground/monorepo-type-share/apps/backend
> tsc -p ./tsconfig.json

➜ tree ./dist 
dist
├──  index.js
└──  tsconfig.tsbuildinfo

とりえあずビルドはできるようになった

ただし、Bundler を指定しているので backend パッケージ内に複数ファイルを置いて import していれば実行できなくなる

node ./dist/index.js 
node:internal/modules/esm/resolve:260
    throw new ERR_MODULE_NOT_FOUND(
          ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/Users/kaito/Playground/monorepo-type-share/apps/backend/node_modules/helpers/dist/index.js' imported from /Users/kaito/Playground/monorepo-type-share/apps/backend/dist/index.js
    at finalizeResolution (node:internal/modules/esm/resolve:260:11)
    at moduleResolve (node:internal/modules/esm/resolve:921:10)
    at defaultResolve (node:internal/modules/esm/resolve:1120:11)
    at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:557:12)
    at ModuleLoader.resolve (node:internal/modules/esm/loader:526:25)
    at ModuleLoader.getModuleJob (node:internal/modules/esm/loader:249:38)
    at ModuleJob._link (node:internal/modules/esm/module_job:126:49) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///Users/kaito/Playground/monorepo-type-share/apps/backend/node_modules/helpers/dist/index.js'
}

Node.js v22.4.0
きむそんきむそん

Bundler を指定するなら大人しくバンドルしたほうが良いよねってことで、例えば esbuild を使って node_modules 以外をバンドルすると実行できる

$ pnpm dlx esbuild ./src/index.ts --format=esm --platform=node --bundle --packages=external --outdir=out
$ node ./out/index.js  
[
  1, 2, 3, 4, 5,
  6, 7, 8, 9
] value
done
きむそんきむそん

ここまでのやり方はあくまで本番環境用で開発中は build 不要でホットリロードできるツールを使うのが一般的。
現代では tsx がファーストチョイスだと思うので tsx を試す

https://github.com/privatenumber/tsx/issues/574

FeatureRequest は飛んでいるが、まだ対応はない模様

ただし

I just ran into this as well in a mono repo. For now you can work around this with the following command line instead:
tsx -C development your-script.ts

というコメントがあるので試してみる

cd packages/helpers
rm -rf dist
cd ../../apps/backend

で消したら、まずはオプションなしで実行してみる

pnpm tsx --tsconfig ./tsconfig.json ./src/index.ts

node:internal/modules/run_main:115
    triggerUncaughtException(
    ^
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/Users/kaito/Playground/monorepo-type-share/apps/backend/node_modules/helpers/dist/index.js' imported from /Users/kaito/Playground/monorepo-type-share/apps/backend/src/index.ts
    at finalizeResolution (node:internal/modules/esm/resolve:260:11)
    at moduleResolve (node:internal/modules/esm/resolve:921:10)
    at defaultResolve (node:internal/modules/esm/resolve:1120:11)
    at nextResolve (node:internal/modules/esm/hooks:746:28)
    at resolveBase (file:///Users/kaito/Playground/monorepo-type-share/node_modules/.pnpm/tsx@4.19.1/node_modules/tsx/dist/esm/index.mjs?1729611581257:2:3212)
    at resolveDirectory (file:///Users/kaito/Playground/monorepo-type-share/node_modules/.pnpm/tsx@4.19.1/node_modules/tsx/dist/esm/index.mjs?1729611581257:2:3584)
    at resolveTsPaths (file:///Users/kaito/Playground/monorepo-type-share/node_modules/.pnpm/tsx@4.19.1/node_modules/tsx/dist/esm/index.mjs?1729611581257:2:4073)
    at resolve (file:///Users/kaito/Playground/monorepo-type-share/node_modules/.pnpm/tsx@4.19.1/node_modules/tsx/dist/esm/index.mjs?1729611581257:2:4447)
    at nextResolve (node:internal/modules/esm/hooks:746:28)
    at Hooks.resolve (node:internal/modules/esm/hooks:238:30) {
  code: 'ERR_MODULE_NOT_FOUND',
  url: 'file:///Users/kaito/Playground/monorepo-type-share/apps/backend/node_modules/helpers/dist/index.js'
}

Node.js v22.4.0

期待通りモジュール解決に失敗する。
-C オプションをつけてみる

$ pnpm tsx --tsconfig ./tsconfig.json -C 'monorepo-custom-conditions/dev' ./src/index.ts
[
  1, 2, 3, 4, 5,
  6, 7, 8, 9
] value
done

いけた。上の FeatureRequest はあくまで tsconfig に書いてあるんだから読んでよっていうリクエストであって、-Cを指定すれば condition を指定できる

help
  -C, --conditions=...                    additional user conditions for conditional exports and
                                          imports
きむそんきむそん

Bundler ではなく Node16 に統一する道はあるか?

たぶんない。
バンドラが読めなくなる。

pnpm build

> frontend@0.1.0 build /Users/kaito/Playground/monorepo-type-share/apps/frontend
> next build

  ▲ Next.js 15.0.0

   Creating an optimized production build ...
Failed to compile.

../../packages/helpers/src/index.ts
Module not found: Can't resolve './types.js'

https://nextjs.org/docs/messages/module-not-found

Import trace for requested module:
./src/app/page.tsx

../../packages/helpers/src/index.ts
Module not found: Can't resolve './functions/assert-min-length.js'
きむそんきむそん

開発中/本番用の参照の切り替え

開発中は src/**/*.ts を見に行って動いてほしいが、プロダクションで動かすときにはソースコードではなくちゃんとビルドされた成果物(dist/**/*.{mjs,cjs}) を見てほしい

きむそんきむそん

node の方は簡単で上で試した通りでいける

開発中は tsx に condition を渡して src を解決させ、本番用には esbuild でバンドルするがパッケージは含めないので実行時に node_modules 以下にあるモジュールが呼び出される。本番であえて --conditions=monorepo-custom-conditions/dev を指定しなければ dist が解決されてOK

本番向けの型チェック(CIで使うような)はどちらの動きにしたいかによるけど

  • d.ts 吐き出すの結構ハマりポイントが多くてそれ自体が割と大変
  • 依存するパッケージをビルドしてからしか型チェックを回せないのがつらい
    • turbo repo 等モノレポ管理ツールで管理出来はするけど、考えることは増える

ので、src を見に行くで良いんじゃないかなと思う

↑を乗り越えてやるなら d.ts もちゃんと書き出した上で tsc -p ./tsconfig.prod.json --noEmit とかで型チェックをする。prod.json に customConditions を指定しなければ吐き出された型定義をちゃんと見れる

きむそんきむそん

Next.js の方は動きがよくわからなかったので試した

  • helpers/dist を削除して pnpm build する → 問題なく通る
  • helpers/src を削除して pnpm build する → 落ちる

てことで、tsconfig を尊重してビルド時にバンドラが dist ではなく src を解決しようとしていることがわかる。

tsconfig をわけることで一応対応できる

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 = {
  typescript: {
    tsconfigPath:
      process.env.NODE_ENV === "development"
        ? "./tsconfig.json"
        : "./tsconfig.prod.json",
  },
  webpack: (config) => ({
    ...config,
    plugins: [
      ...config.plugins,
      new NextEntryPlugin("monorepo-custom-conditions/dev"),
    ],
  }),
}

export default nextConfig
きむそんきむそん

まとめ

  • モノレポ内の共通ライブラリの参照方法を分けるのに customConditions が便利
    • 本番用ではちゃんと build したファイルを見る、開発中は customConditions に指定されたトランスパイル前の ts, tsx ファイルを参照させる、みたいなことができる
    • 依存する共通ライブラリの開発サーバーを立てる必要がないし、定義ジャンプも d.ts ではなくソースコードに飛んでくれる
  • custom condiion は最近のモジュール解決の責務も持つツールチェーンでは概ね解決できる
    • tsconfig: customConditions
    • node, tsx: -C, --conditions=xxx
    • Next.js, webpack: 公式のサポートはないが自前の Plugin を差し込むことで対処可
    • Vite: resolve.conditions
  • custom condition の condition 名は一般のライブラリとバッティングして誤解決しうるので汎用的でない固有の命名のキーを指定するほうが良さそう
  • customConditions ではなく exports.import とかに直接書いてしまう方法も気持ち悪いが一応ある。少なくともその共通パッケージを公開する場合は customConditions が必要。Node.js で実行する場合も基本は customConditons のが良い。それ以外だと customConditions なしでもおそらく問題ない
    • 公開するのがNGなのはTypeScriptソースをトランスパイルせずに公開しているってことなので当然NG
    • Node.js で実行云々はバンドルが面倒だから。
      • Bundler 指定しているのでソースコードのバンドルは必須
      • esbuild は「全部バンドルする」「dependencies以外はバンドルする」「バンドルしない」は簡単だけど、「dependencies の一部だけバンドルする」は面倒で設定の管理が煩雑になる
  • 公開する or Node.js で利用するなら
    • 本番用にモジュール解決する箇所では condition が指定されていないことに注意してビルドシステムを組む
    • Nodeで実行するなら開発時は tsx に指定して、本番向けは esbuild で解決するが condition 指定しないのがオススメ
    • FEなら dev, prod 向けどちらも同じバンドラを通るので成果物を参照させたいなら NODE_ENV とかを見て分岐を書く必要がある。とはいえバンドルが無駄に複雑化するだけな気がするからどっちも TS ファイル直参照で良いんじゃね?感はある
  • custom condition に限らず exports でトランスパイル前の ts ファイルを直接参照する場合は気にするポイントがいくつかある
    • paths alias を使わないこと
      • apps で他から参照されないなら良いが、参照されうる共通パッケージでは禁止
    • 単一の TypeScript ファイルを複数の tsconfig.json で解釈することになるので、モジュール解決関連のオプションを統一すること
      • (FEもある場合は)Node16 に統一は無理があるので、moduleResolution: Bundler, "module": "ESNext", が基本
      • Node で実行したい場合には Bundler 指定しているので Node にモジュール解決させるべきでなく、esbuild 等のバンドラでソースコードをバンドルし、吐き出された単一ファイルを実行するのが良い
        • pnpm dlx esbuild ./src/index.ts --format=esm --platform=node --bundle --packages=external --outdir=out
        • esbuild 以外でも良いが、dual package 対応はいらないので tsup 等を持ち出す必要はないし esbuild がファーストチョイスな気がする
        • ソースコードはバンドルはするけど node_modules のバンドルは(場合によっては)かなり大変なので避けるのをオススメ
きむそんきむそん

デフォルトの condition を見る