モノレポで依存パッケージの型解決に使えそうな tsconfig の customConditions を試す
customConditions がモノレポで依存パッケージの型解決に良さげなので見る
TS5.0 から追加されていたらしい
exports フィールドに import, require, types 等標準の key 以外を指定したものを解決できるようにするオプション
{
// ...
"exports": {
".": {
"my-condition": "./src/bar.ts",
"node": "./dist/bar.cjs",
"import": "./dist/bar.mjs",
"require": "./dist/bar.cjs",
"types": "./dist/bar.d.ts"
}
}
}
のように指定されているときに
{
"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 モジュールを用意する
{
"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
を指定する。
{
"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 の設定を追加する
{
"name": "frontend",
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"typecheck": "tsc -p ./tsconfig.json --noEmit"
},
"devDependencies": {
"helpers": "workspace:*"
}
}
※書いてなかったけど pnpm workspace を使っている
{
"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
を見に行くのでエラーになる。それはそう。
公式のサポートはなさそうだけど、ワークアラウンドを見つけた
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 ファイルを直接参照して開発を行うことができる
ちなみに今回は試してないけど vite には公式のオプションがあるのでワークアラウンドすら不要で参照できるはず
helpers の方で path alias を使うと解決できるのか?
相対パス指定が嫌なときによく使われる
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
}
}
があると動かなそうだけど解決できる?
helpers 内で相対パスで参照していた解決を一部 path alias を使って解決してみる
ソースコードを直接見ているときは当然解決できて型エラーも特に起きない
利用している側からは参照できなくなっていた
バンドラも解決できていない
ということで、少なくとも簡単にはできなそう。
この方針でやる場合には、他のパッケージから利用されるパッケージは path alias を利用しないべき
あと影響ありそうなのが、モジュール解決周りのオプションは利用する側とパッケージ側で統一できていないと潜在的な問題がありそう。
わかりやすいのは moduleResolution の Node16
と Bundler
. 前者は hoge.js
の形でインポートを要求してくるが後者で読むときは拡張子があると読めない
helpers の moduleResolution を Node16 にしてみる
.js
がないので型エラーが出るようになる。.js
をつけて回る。
無理かと思ったら解決できちゃった。あれ、Bundler のときって .js
が付いていても解決できるんだっけ?
→そうだった。
となると逆パターンは無理なはずなので、node で実行するパターンを試すときに一緒に試してみる
node で実行する場合
パッケージを追加
{
"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 を追加してみる
{
"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 にしてみる
{
"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 を試す
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 を指定できる
-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 をわけることで一応対応できる
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
- tsconfig:
- 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 のバンドルは(場合によっては)かなり大変なので避けるのをオススメ
- (FEもある場合は)Node16 に統一は無理があるので、
- paths alias を使わないこと
デフォルトの condition を見る