pnpm workspaceの使い方とfilter実戦:同じButtonを3回コピペしたら卒業
pnpm-workspace.yamlとworkspace:プロトコル、--filterでの対象実行、hoistingで省容量になる仕組みを、Claude Codeのレビューと合わせて実例で解説。
同じButtonコンポーネントを、3つのプロジェクトにコピペしている自分に気づいたのは、深夜2時でした。
メディアの本体サイト、管理画面、ランディングページ。それぞれ別リポジトリで、それぞれに同じButton、同じZodスキーマ、同じ日付フォーマット関数。1か所直すと、残り2つを直し忘れる。直し忘れたまま本番に出て、管理画面だけボタンの色が古い。そんな事故を3回やって、僕はようやく観念しました。
リポジトリを分けすぎていたんです。
そこで pnpm workspace に移しました。npm や yarn でもモノレポは組めますが、僕がpnpmを選んだのは、workspace: という仕組みで「社内パッケージを絶対にnpmから取ってこない」と宣言できるから。そしてディスクを食わないから。この記事は、その2点を中心に、実際に動く最小構成と --filter の使い分けまでまとめます。
この記事の要点
- pnpm workspace は
pnpm-workspace.yamlをルートに置くだけで始まる。複数パッケージを1つのGitリポジトリで回す仕組み。 - 社内パッケージの依存は
"@acme/ui": "workspace:*"と書く。これでnpmレジストリから誤って同名パッケージを取ってくる事故が消える。 pnpm --filterで「このパッケージだけ」「変更に関係するパッケージだけ」を実行できる。CIの待ち時間がそのまま減る。- pnpmが速くて省容量なのは、依存を1か所に貯めてハードリンクで各パッケージへ繋ぐから。同じ依存を何度もコピーしない。
- Claude Code は雛形作りよりも「依存の向きが逆になっていないか」のレビューで効く。点検→最小差分の順で頼む。
タスク実行そのものをもっと速くしたい(キャッシュや並列ビルド)なら、別記事のClaude CodeとNxでモノレポを高速化とTurborepoでビルドを並列化・キャッシュに送ります。この記事はあくまで pnpm workspace 単体の土台に絞ります。
pnpm workspaceは「リポジトリを増やさない」装置
workspace を一言でいうと、複数のパッケージを1つのGitリポジトリで管理する仕組みです。難しい概念ではありません。
ルートに pnpm-workspace.yaml を1枚置く。すると pnpm は、そこに書かれたディレクトリ群を1つの作業単位として扱い始めます。公式の pnpm Workspaces も「pnpmはモノレポ(複数パッケージリポジトリ)を組み込みでサポートする」と書いています。
僕の感覚だと、こういうサインが出たら移し時です。
- 同じコンポーネントや関数を、別プロジェクトにコピペし始めた
- 「共通の型」をどこに置くか、毎回チャットで相談している
- 1つの機能追加で、2つのリポジトリにPRを出す羽目になっている
逆に、アプリが1つしかなくて当分増えないなら、無理に分ける必要はありません。workspace は「将来パッケージが増える」前提のときに効きます。
なぜpnpmは速くて、容量も食わないのか
npm や yarn(classic)では、依存パッケージを各プロジェクトの node_modules に丸ごとコピーします。Reactを3つのアプリで使えば、Reactの実体が3つ。これがディスクを圧迫し、インストールも遅くする原因でした。
pnpm の発想は違います。ダウンロードした依存は、マシン上の1か所(コンテンツ管理ストア)に貯める。各プロジェクトの node_modules には、その実体への「ハードリンク」だけを張る。実体は1つ、入口だけ複数、というイメージです。
| npm/yarn classic | pnpm | |
|---|---|---|
| 依存の置き場所 | 各プロジェクトに丸ごとコピー | マシンに1か所、リンクで共有 |
| 同じ依存を3プロジェクトで使うと | 実体が3つ | 実体は1つ |
| node_modules の構造 | フラット(平坦化) | シンボリックリンクで実依存だけ見える |
このリンク方式のおかげで、workspace内で @acme/ui を5つのアプリが使っても、実体のコピーは増えません。さらに pnpm は node_modules を平坦化しません。各パッケージは、自分が package.json に書いた依存しか見えない。これを「巻き上げ(hoisting)を避ける」と言います。
巻き上げを避けると何が嬉しいか。npmだと、たまたま隣のパッケージが入れた依存を、自分は書いていないのに import できてしまう。これがある日その隣パッケージを消した瞬間に壊れる。「幽霊依存(phantom dependency)」と呼ばれる地雷です。pnpmは設計でこれを踏まないようにしている、というのが大きい。
完成形:小さく始める4パッケージ
最初から巨大な基盤リポジトリを作る必要はありません。僕がいつも始める形はこの4つです。
acme-workspace/
apps/
web/
src/main.ts
package.json
admin/
src/main.ts
package.json
packages/
config/
src/index.ts
package.json
ui/
src/index.ts
package.json
pnpm-workspace.yaml
package.json
tsconfig.base.json
.npmrc
CLAUDE.md
依存の向きはこうです。アプリ(apps/*)が共通パッケージ(packages/*)を使う。逆は絶対にやらない。
flowchart LR
web["apps/web (@acme/web)"] --> ui["packages/ui (@acme/ui)"]
web --> config["packages/config (@acme/config)"]
admin["apps/admin (@acme/admin)"] --> ui
admin --> config
使いどころは少なくとも3つあります。
packages/uiにフォーム部品や表示ロジックを置き、apps/webとapps/adminの両方から使う。packages/configに環境変数名・APIエンドポイント・フラグ名を集約し、設定の食い違いをなくす。- 後から
packages/contractsを足して、APIの型やZodスキーマをフロントとサーバーで共有する。
注意は1つだけ。packages/everything のような「何でも入る巨大パッケージ」を作らないこと。どこからでも触れる共通置き場は、変更したときの影響範囲が読めなくなります。共通化するなら「どのアプリから呼ばれても同じ意味になるもの」だけ。
最小構成をコピペで作る
実際に手を動かしたほうが早いです。前提は pnpm 11.5.2、Node.js 22以上(pnpm 11はNode 22必須・pure ESMになりました)。
まずルートに pnpm-workspace.yaml。公式の pnpm-workspace.yaml のとおり、packages で含めるディレクトリを指定し、! で除外もできます。
packages:
- "apps/*"
- "packages/*"
# 依存のバージョンを1か所で揃える「カタログ」
catalog:
typescript: ^5.8.3
ルートの package.json は、個別パッケージに仕事を投げるだけにします。スクリプト名に最初から --filter を仕込んでおくのがコツです。
{
"name": "acme-workspace",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"check:web": "pnpm --filter @acme/web build",
"build": "pnpm -r --sort --if-present build",
"test": "pnpm -r --if-present test",
"lint": "pnpm -r --if-present lint",
"changed:test": "pnpm --filter \"...[origin/main]\" --if-present test"
},
"devDependencies": {
"typescript": "catalog:"
}
}
tsconfig.base.json は共通の土台です。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true
}
}
.npmrc で、解決の曖昧さを潰します。
link-workspace-packages=false
save-workspace-protocol=rolling
shared-workspace-lockfile=true
strict-peer-dependencies=true
auto-install-peers=false
ここで link-workspace-packages=false と workspace:* をセットで考えるのが肝です。pnpm公式は「workspace: プロトコルを使うと、pnpmはローカルのworkspaceパッケージ以外には絶対に解決しない(refuse to resolve to anything other than a local workspace package)」と明言しています。つまり @acme/ui をうっかりnpmレジストリから取ってくる事故が、仕組みで防がれます。
共通設定パッケージは小さく始めます。
{
"name": "@acme/config",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
}
export const appConfig = {
productName: "Acme Workspace",
supportEmail: "[email protected]",
publicSiteUrl: "https://example.com"
} as const;
packages/ui は @acme/config を workspace 依存として参照します。ここが workspace:* の出番です。
{
"name": "@acme/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@acme/config": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
}
}
import { appConfig } from "@acme/config";
export function renderPrimaryButton(label: string): string {
return `[${appConfig.productName}] ${label}`;
}
最後にアプリ側です。@acme/config と @acme/ui の両方を workspace 依存で繋ぎます。
{
"name": "@acme/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@acme/config": "workspace:*",
"@acme/ui": "workspace:*"
}
}
apps/web/tsconfig.json は土台を継いで src を含めるだけ。
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}
import { appConfig } from "@acme/config";
import { renderPrimaryButton } from "@acme/ui";
console.log(appConfig.publicSiteUrl);
console.log(renderPrimaryButton("Start trial"));
ここまで置いたら、まとめて実行します。
# Corepack経由でpnpmのバージョンを固定して使う
corepack enable
corepack prepare [email protected] --activate
# 依存をインストール(workspace内のリンクもここで張られる)
pnpm install
# webだけビルド
pnpm --filter @acme/web build
# 依存順を考慮して全パッケージをビルド
pnpm -r --sort --if-present build
@acme/admin も同じ形で作れば、UIと設定を共有した2つ目のアプリが、コピペなしで手に入ります。
workspace: プロトコルの細かい挙動
workspace:* 以外にも書き方があります。普段は *(常に最新のローカル版に追従)で十分ですが、知っておくと公開時にハマりません。
| 書き方 | 意味 |
|---|---|
workspace:* | ローカルの該当パッケージに常に追従 |
workspace:^ / workspace:~ | publish時に ^1.2.3 / ~1.2.3 へ変換される |
workspace:../foo | 相対パスで直接指す |
workspace:foo@* | エイリアス(別名で参照) |
大事なのは公開時の挙動です。pnpm公式によれば、パッケージをアーカイブに固める(pack/publish)瞬間に、workspace: の依存はworkspace内の実バージョンへ自動で書き換えられます。つまり workspace:* のまま npm に公開しても、外の人が壊れた依存を踏むことはない。逆に言えば、社内だけで使うパッケージは private: true にしておけば、この変換を気にする必要すらありません。
--filter で必要なパッケージだけ動かす
workspace の真価は、ここで出ます。pnpm の Filtering は、workspaceの一部だけにコマンドをかける機能です。全パッケージを毎回ビルドするのは、CIでも日常作業でも無駄。影響範囲だけ動かします。
# webパッケージだけビルド
pnpm --filter @acme/web build
# webと、webが依存している側(config, ui)もビルド
pnpm --filter @acme/web... build
# uiと、uiを使っているアプリ側(web, admin)もテスト
pnpm --filter ...@acme/ui test
# mainブランチとの差分に関係するパッケージだけテスト
pnpm --filter "...[origin/main]" --if-present test
落とし穴は ... の向きです。ここを取り違える人が本当に多い(僕もやりました)。
@acme/web...(後ろに点々) = webと、webが依存している側...@acme/ui(前に点々) = uiと、uiに依存している側
UIを変更したのに @acme/ui... でテストすると、uiを使っている web/admin を見落とします。「変えたものの利用者をテストしたい」なら点々は前。覚え方は「矢印が指す向きに点々が伸びる」です。
GitHub Actions では、差分ベースで絞れます。
name: workspace-check
on:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: corepack enable
- run: corepack prepare [email protected] --activate
- run: pnpm install --frozen-lockfile
- run: pnpm --filter "...[origin/main]" --if-present lint
- run: pnpm --filter "...[origin/main]" --if-present test
- run: pnpm --filter "...[origin/main]" --if-present build
小さいうちは全件実行でも問題ありません。ただ、メディアやSaaSを続けるとパッケージは必ず増えます。最初から --filter 前提のスクリプト名にしておくと、後で書き換える手間とCI課金が両方減ります。なお「キャッシュで2回目を一瞬で終わらせる」「依存グラフを並列で走らせる」はpnpm単体の範囲外なので、Turborepoの記事に任せます。
Claude Codeには「パッケージの境界」を先に教える
ここで Claude Code を足します。価値は雛形生成より、人間が見落とす足場のレビューにあります。workspace:* が抜けていないか、依存の向きが逆になっていないか、CIで全パッケージを無駄にビルドしていないか。
Claude Code公式の monorepos and large repos ガイドにあるとおり、大きいリポジトリでルートの指示だけで全部読ませると、関係ないパッケージの情報が文脈に入りすぎます。だからルートの CLAUDE.md には全体ルールだけ書く。
# Acme Workspace
This repository is a pnpm workspace.
Packages:
- apps/web: customer-facing TypeScript app
- apps/admin: internal admin app
- packages/ui: shared UI helpers
- packages/config: shared runtime constants
Rules:
- Use pnpm, not npm or yarn.
- Add internal dependencies with workspace:*.
- Run focused commands with pnpm --filter before full workspace commands.
- Do not move business logic into packages/ui.
パッケージごとの細かい癖は、そのディレクトリの CLAUDE.md に分けます。例えば packages/ui/CLAUDE.md には「表示だけ。API呼び出しと課金判定は禁止」と書く。こうすると Claude Code がいきなり巨大な抽象化を作る確率が下がります。
最初の依頼は、編集ではなく点検から始めると安全です。
claude -p "
Inspect this pnpm workspace. Do not edit files yet.
List the package graph, scripts, and any dependency direction that looks risky.
Then propose the smallest change needed to make apps/web and apps/admin share UI helpers.
"
「先に点検して、最小差分を提案して」。この型だけで、暴走の半分は防げます。指示ファイルの整え方はCLAUDE.mdベストプラクティスに詳しく書きました。
僕がやらかした失敗3つ
正直に書きます。最初のworkspaceは事故だらけでした。
1つ目は、内部パッケージを普通のsemverで書いたこと。 "@acme/ui": "^0.1.0" と書いてしまい、後日たまたまnpmに同名パッケージが存在して、外の別物を取ってきていた。workspace:* に直したら一発で解決しました。社内依存は常に workspace:*、これは例外なしのルールにしています。
2つ目は、共通パッケージにアプリ固有の処理を入れたこと。 packages/ui に管理画面専用のAPI呼び出しを入れたら、web側の依存まで濁って、UIを直すたびに管理画面のテストが落ちる。共通パッケージは「どのアプリから呼ばれても同じ意味になるもの」だけ、と決め直してから静かになりました。
3つ目は、... の向きを取り違えてテストを漏らしたこと。 uiを直したのに @acme/ui... でテストして、利用者のwebを見ていなかった。本番でボタンが崩れて気づきました。今はCIで ...[origin/main] を使い、差分起点で利用者まで自動で巻き込むようにしています。
releaseが必要ならChangesetsを足す
全部 private ならリリース管理は不要です。ただ packages/ui や packages/config を社内レジストリやnpmに出すなら、バージョン管理を後回しにしない方が安全。pnpm公式も、workspace内パッケージのバージョニングはpnpm単体ではなく Changesets などの利用を案内しています。
pnpm add -Dw @changesets/cli
pnpm changeset init
pnpm changeset
pnpm changeset version
pnpm -r publish --access public
前述のとおり、publish時に workspace:* は実バージョンへ変換されます。外に出さないパッケージは private: true のままに。リリースPRのレビューもClaude Codeに任せると、「公開対象だけがバージョン上げされているか」「アプリは private のままか」を機械的に見てくれます。
よくある質問
Q. npm workspaces や yarn workspaces ではダメですか?
A. 同じことはできます。pnpmを選ぶ理由は、workspace: で社内依存を厳格に縛れること、ディスクを共有して省容量なこと、巻き上げを避けて幽霊依存を踏みにくいこと。この3点が効くなら pnpm が向いています。
Q. workspace:* と workspace:^ の違いは?
A. * はローカルの最新に常に追従。^ は publish時に ^1.2.3 のような通常のバージョン範囲へ変換されます。外部公開するパッケージなら ^、社内だけなら * で十分です。
Q. pnpm -r と pnpm --filter はどう使い分けますか?
A. -r(recursive)は全パッケージ対象。--filter は特定パッケージや差分に関係する範囲だけ。日常とCIでは --filter で絞り、全体ビルドが必要なときだけ -r を使う、が省エネです。
Q. ビルドのキャッシュや並列化はpnpmだけでできますか? A. pnpmは依存解決とパッケージ実行が役割で、ビルド結果のキャッシュや高度な並列化は守備範囲外です。そこはNxやTurborepoを上に重ねます。
Q. 既存のnpm/yarnプロジェクトから移行できますか?
A. できます。pnpm-workspace.yaml を置き、各 package.json の社内依存を workspace:* に書き換え、pnpm install でロックファイルを作り直すのが基本の流れです。一気に全部より、1パッケージずつ移すと事故が少ないです。
まとめ:まず境界、それから自動化
pnpm workspace は、モノレポを難しくする道具ではありません。共通UI・設定・型を「コピーではなく依存関係」として扱うための、小さな土台です。workspace:* で社内依存を縛り、--filter で必要な範囲だけ動かす。これだけで、深夜2時のコピペ作業からは卒業できます。
始める順番はいつも同じ。pnpm-workspace.yaml → workspace:* → 小さな共通パッケージ → CLAUDE.md → --filter 付きCI。いきなり高度なビルドシステムを足す必要はありません。土台が固まってから、速度が欲しくなったらNxやTurborepoを重ねれば十分です。
この記事で紹介した内容を実際に試した結果
Windows環境、Node.js 22、Corepack、pnpm 11.5.2で全部通しました。workspace:* を外した瞬間に内部パッケージの解決が曖昧になり、外部の同名パッケージを引く事故が再現できました。...@acme/ui と @acme/ui... の取り違えも、UI変更時に利用者側テストを漏らす形でちゃんと再発しました。実務でいちばん効いたのは、Claude Codeに「編集前のパッケージグラフ点検」を毎回挟ませること。不要な共通化と循環依存を、PRに出す前に止められるようになりました。チームで運用ルールを固めたい人はClaude Code研修・相談から、教材は教材一覧からどうぞ。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。