npmパッケージ公開でハマる順番に、tsupでESM/CJS両対応する手順
npm publishでESM/CJS両対応のパッケージを公開する手順。package.jsonのexports、tsupビルド、型定義、npm packの中身確認、CI自動公開まで実例で。
初めて自作のnpmパッケージを公開した日、僕は得意げに npm publish を叩きました。緑のログが流れて「公開成功」。やった、と。
その3分後、別のプロジェクトで npm install して require した瞬間、Error [ERR_REQUIRE_ESM] が出ました。ESMでしか読めないパッケージを、CJSのコードから呼んでしまったんですね。しかも公開済みなので取り消せない。慌ててバージョンを上げて出し直しました。
このとき痛感したのは、**npm公開でハマるのは「コードの中身」じゃなくて「公開物の形」**だということ。package.json の exports をどう書くか、ビルドでESMとCJSの両方を吐くか、型定義(.d.ts)を同梱するか、npm pack で実際に何が送られるか。ここを順番に押さえないと、インストールはできるのに使えないパッケージが出来上がります。
この記事では、TypeScript製の小さな文字列ユーティリティを題材に、その順番をなぞります。コードはWindowsの一時ディレクトリで npm install から npm pack まで通したものです。
この記事の要点
- npm公開で詰まるのはコードより「公開物の形」。
package.jsonのexports/types/filesを先に決める。 - ESMとCJSの両対応は手書きすると地獄。tsupに
format: ["esm", "cjs"]とdts: trueを任せるのが一番ラク。 npm publishの前に必ずnpm pack --dry-runでtarballの中身を見る。distと型定義だけが入っていれば合格。- バージョンはsemverで機械的に。互換を壊したらmajor、機能追加はminor、修正はpatch。
- CI公開はnpmのTrusted Publishing(OIDC)が今の本命。長期トークンを置かずに済む。
公開物の「契約書」を先に決める
package.json は、利用者との契約書です。ここが曖昧だと、Claude Codeに頼んでも見た目だけ整ったファイルが出てきます。実装を書く前に、次の5項目を埋めておくと後戻りが激減します。
| 項目 | 今回の決定 | なぜ先に決めるか |
|---|---|---|
| パッケージ名 | @acme/string-kit | scope付き公開は初回に --access public が要る |
| 対応ランタイム | Node.js / TypeScript | ESM import とCJS require の両方で読めるか |
| ビルド形式 | tsupでESM・CJS・型定義を生成 | main / module / types / exports は連動する |
| 公開物 | dist だけ | ソースやテストをtarballに入れない |
| 検証 | Vitest、npm pack --dry-run、distのimport確認 | 公開前に中身を人間が見る |
特に type: "module"、main、exports、types は互いに関係しています。あとから1つだけ直すと別の利用者が壊れる、という詰みになりがちなので、最初にまとめて決めます。
ここからの全体像はこの順番です。コードだけ、CIだけ、と部分最適に走らないための地図だと思ってください。
flowchart LR
A["設計メモ"] --> B["package.json"]
B --> C["src/index.ts"]
C --> D["Vitest"]
D --> E["tsup build"]
E --> F["npm pack dry-run"]
F --> G["CI publish"]
CLIとして配る話は Claude CodeでCLIツールを開発する に分けています。日々の依頼の型は Claude Code生産性Tips と合わせて読むと、公開作業もスムーズになります。
空ディレクトリで最小構成から始める
いきなり既存リポジトリに混ぜると、ビルドが落ちたとき原因がわかりません。まず空の箱で、ビルド・テスト・packが通ることを確認します。
mkdir string-kit
cd string-kit
npm init -y
npm install -D typescript tsup vitest @types/node
mkdir src scripts
package.json を次のように書きます。ここが今日の核心です。main はCJS利用者向けの入口、module は古いbundler向けの補助、types は型定義、exports は現在の標準的な入口制御です。files を絞ると、テストや設定や下書きをtarballに巻き込みにくくなります。
{
"name": "@acme/string-kit",
"version": "0.1.0",
"description": "Small TypeScript string utilities published as an npm package.",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist", "README.md", "LICENSE"],
"sideEffects": false,
"scripts": {
"build": "tsup",
"test": "vitest run",
"docs": "node scripts/write-readme.mjs",
"test:pack": "npm pack --dry-run",
"prepublishOnly": "npm run test && npm run build && npm run test:pack"
},
"keywords": ["string", "typescript", "utilities"],
"license": "MIT",
"devDependencies": {
"@types/node": "^22.15.0",
"tsup": "^8.5.0",
"typescript": "^5.8.0",
"vitest": "^3.2.0"
}
}
exports の中で types を一番上に書くのには理由があります。TypeScriptは上から条件を読むので、型定義を先頭に置くと解決が安定します。prepublishOnly にテストとビルドとpack確認を入れておくと、npm publish のたびに自動で門番が走ります。
TypeScript設定は、パッケージ本体からはJSを出さず、出力はtsupに任せます。moduleResolution: "Bundler" は、exports を前提にしたライブラリ開発で今どきの解決に寄せる設定です。
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "tsup.config.ts"]
}
動く実装とテストを書く
公開物に疑似コードを載せるのが一番まずいので、実際に動く関数を4つだけ用意します。slugify はURLやファイル名向けの英数字スラッグに変換、truncate は最大長を守って省略、interpolate はREADMEや通知文の簡単な置換、byteLength はUTF-8のバイト数を返します。
export function slugify(input: string): string {
return input
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function truncate(input: string, maxLength: number, suffix = "..."): string {
if (!Number.isInteger(maxLength) || maxLength < 1) {
throw new RangeError("maxLength must be a positive integer");
}
if (suffix.length >= maxLength) {
throw new RangeError("suffix must be shorter than maxLength");
}
if (input.length <= maxLength) return input;
return `${input.slice(0, maxLength - suffix.length)}${suffix}`;
}
export function interpolate(template: string, values: Record<string, string | number>): string {
return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key: string) => {
return Object.hasOwn(values, key) ? String(values[key]) : match;
});
}
export function byteLength(input: string): number {
return new TextEncoder().encode(input).length;
}
テストは代表例だけでなく、踏みやすい境界も入れます。成功例・境界値・例外・Unicodeの4種を必ず通すと、公開後に利用者がハマるバグがぐっと減ります。
import { describe, expect, it } from "vitest";
import { byteLength, interpolate, slugify, truncate } from "./index";
describe("slugify", () => {
it("turns a title into an npm-friendly slug", () => {
expect(slugify("Hello npm Package!")).toBe("hello-npm-package");
});
it("removes accents before replacing separators", () => {
expect(slugify("Crème brûlée utils")).toBe("creme-brulee-utils");
});
});
describe("truncate", () => {
it("keeps short text unchanged", () => {
expect(truncate("short", 10)).toBe("short");
});
it("adds a suffix inside the requested length", () => {
expect(truncate("Claude Code package", 12)).toBe("Claude Co...");
});
it("rejects invalid lengths", () => {
expect(() => truncate("abc", 2)).toThrow(RangeError);
});
});
describe("interpolate", () => {
it("replaces known placeholders and keeps unknown ones", () => {
expect(interpolate("Hi {{ name }}, ship {{pkg}} {{missing}}", {
name: "Masa",
pkg: "@acme/string-kit",
})).toBe("Hi Masa, ship @acme/string-kit {{missing}}");
});
});
describe("byteLength", () => {
it("counts UTF-8 bytes", () => {
expect(byteLength("npm")).toBe(3);
expect(byteLength("日本語")).toBe(9);
});
});
tsupでESM・CJS・型定義を一発で出す
ESMとCJSを手書きで両対応させようとすると、二重ビルドのスクリプトを書く羽目になって挫折します。tsupはこれを設定数行で片付けてくれます。outExtension でESMを .js、CJSを .cjs に分けると、type: "module" のパッケージでもCJS利用者が require で読めます。sourcemap: false は、内部ソースを公開物に余計に入れないためです。デバッグ用に欲しければ公開時だけ意図して有効化します。
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: false,
minify: false,
target: "es2022",
outDir: "dist",
outExtension({ format }) {
return { js: format === "esm" ? ".js" : ".cjs" };
},
});
dts: true がポイントで、これだけで dist/index.d.ts が出ます。型定義を同梱しないと、TypeScript利用者の手元では any 扱いになって補完が効きません。せっかくTypeScriptで書いたのに型が消える、という残念な公開を防げます。
READMEもAIに丸投げせず、生成スクリプトで最低限の使用例を固定しておくと安全です。公開前に npm run docs を走らせれば、インストール方法と使い方が古いまま残る事故を減らせます。
import { writeFile } from "node:fs/promises";
const fence = String.fromCharCode(96).repeat(3);
const readme = `# @acme/string-kit
Small TypeScript string utilities packaged with tsup.
## Install
${fence}bash
npm install @acme/string-kit
${fence}
## Usage
${fence}ts
import { slugify, truncate } from "@acme/string-kit";
console.log(slugify("Hello npm Package!"));
console.log(truncate("Claude Code package", 12));
${fence}
`;
await writeFile(new URL("../README.md", import.meta.url), readme);
npm packで「送られる中身」を公開前に見る
npm publish の直前に初めてtarballを見るのは遅すぎます。npm packの公式ドキュメントにあるとおり、--dry-run は実際にnpmへ送られるファイル一覧に近いものを、何も生成せずに表示します。files の効き具合、README、LICENSE、型定義が意図どおり入っているかをここで確認します。
npm run docs
npm test
npm run build
npm pack --dry-run
node -e "import('./dist/index.js').then((m)=>console.log(m.slugify('Hello ESM')))"
node -e "const m=require('./dist/index.cjs'); console.log(m.slugify('Hello CJS'))"
最後の2行が、冒頭で僕が踏んだ ERR_REQUIRE_ESM の再発を止める保険です。ESMの import() とCJSの require を両方ローカルで叩いて、どちらも値が返れば両対応できています。
さらに念を入れるなら、生成した .tgz を別ディレクトリにインストールします。ローカルの src を直接読んで成功しているだけでは、利用者の環境で動く証拠にはなりません。
npm pack
mkdir ../string-kit-smoke
cd ../string-kit-smoke
npm init -y
npm install ../string-kit/acme-string-kit-0.1.0.tgz
node -e "import('@acme/string-kit').then((m)=>console.log(m.truncate('Claude Code package', 12)))"
ここまで通ると、使いどころが具体的に見えてきます。社内の複数アプリで文字列整形を共通化するとき、各アプリに同じ slugify を貼るよりテスト済みパッケージへ寄せたほうがレビューが軽い。ドキュメント生成では interpolate でREADME・通知文・リリースノートの文言を統一できる。記事サイトやCMSでは byteLength や truncate でdescriptionやカードの長さを機械的に揃えられます。
semverでバージョンを機械的に上げる
公開後の更新は感覚でやると荒れます。npmはsemver(セマンティックバージョニング)が前提なので、major.minor.patch を意味で決めます。
| 変更 | 上げる桁 | 例 | 判断の目安 |
|---|---|---|---|
| 互換を壊す変更 | major | 1.4.2 → 2.0.0 | 関数名変更、引数削除、戻り値の型変更 |
| 後方互換の機能追加 | minor | 1.4.2 → 1.5.0 | 新しい関数を足した |
| 後方互換の修正 | patch | 1.4.2 → 1.4.3 | バグ修正、型の補正 |
npm version patch(または minor / major)を使うと、package.json のバージョンを上げてgitタグまで打ってくれます。手でファイルを書き換えてタグを忘れる、という地味な事故が消えます。
リリース管理をもっと丁寧にやるなら、Claude CodeとChangesetsでバージョン管理する と組み合わせると、CHANGELOGとrelease PRが分かれて「これはpatchかminorか」のレビューがやりやすくなります。
GitHub ActionsでTrusted Publishingする
公開を手元の npm publish だけに頼ると、手順の属人化やトークン漏れのリスクが残ります。今の本命はnpmのTrusted Publishing(OIDC)で、GitHub ActionsやGitLab CI/CDから短命トークンで公開できます。長期npmトークンをSecretsに置かずに済むのが大きい。
押さえる前提が3つあります。npm CLIは11.5.1以上、Nodeは22.14.0以上が必要です。ワークフローには id-token: write の権限が要ります。そして公開元がpublicリポジトリなら、provenance(出所の署名)が自動で付きます。npm側でtrusted publisherを設定したうえで、release公開時だけ npm publish を走らせる構成にします。
name: package
on:
push:
branches: [main]
pull_request:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: npm
- run: npm ci
- run: npm run docs
- run: npm test
- run: npm run build
- run: npm pack --dry-run
publish:
if: github.event_name == 'release'
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: npm
- run: npm ci
- run: npm run docs
- run: npm test
- run: npm run build
- run: npm publish --access public
一点、新しめの注意があります。2026年5月20日以降に作るtrusted publisher設定は、許可するアクション(例: npm publish)を明示的に1つ以上選ぶ必要があります。以前は npm publish が既定で選ばれていましたが、今は空のままだと公開が通りません。設定画面でチェックを入れ忘れないようにします。まだ従来のトークン運用なら、provenanceと2FAの設定だけは先に確認しておくと安心です。GitHub Actionsの組み方全般は Claude CodeのCI/CD設定ガイド も参考になります。
コピペで動く検証スクリプト
ここまでの「公開前チェック」を1ファイルにまとめておきます。scripts/preflight.mjs として置き、node scripts/preflight.mjs で実行すれば、ビルド・テスト・pack確認・ESM/CJS両方のスモークテストを順に走らせて、どこで落ちたかを日本語で教えてくれます。公開ボタンを押す前の最後の門番です。
// scripts/preflight.mjs — npm公開前の自動チェック
import { execSync } from "node:child_process";
// コマンドを順に実行し、落ちたら原因を出して止める
function run(label, cmd) {
process.stdout.write(`\n▶ ${label}: ${cmd}\n`);
try {
execSync(cmd, { stdio: "inherit" });
} catch {
console.error(`✗ ${label} で失敗。ここを直してから再実行してください。`);
process.exit(1);
}
}
run("README生成", "npm run docs");
run("テスト", "npm test");
run("ビルド", "npm run build");
run("tarball中身の確認", "npm pack --dry-run");
// ESMとCJSの両方で実際に読み込めるか確認する
run(
"ESMスモークテスト",
`node -e "import('./dist/index.js').then(m=>{if(m.slugify('Hello ESM')!=='hello-esm')throw new Error('ESM壊れ');console.log('ESM OK')})"`,
);
run(
"CJSスモークテスト",
`node -e "const m=require('./dist/index.cjs');if(m.slugify('Hello CJS')!=='hello-cjs')throw new Error('CJS壊れ');console.log('CJS OK')"`,
);
console.log("\n✓ 全チェック通過。npm publish に進めます。");
このスクリプトの嬉しいところは、ESMもCJSも「読めるか」だけでなく「正しい値を返すか」まで見ている点です。slugify('Hello ESM') が hello-esm にならなければその場で止まります。冒頭の僕の事故は、まさにこの一歩を省いたせいで起きました。
よくある質問
Q. exports を書けば main はもう要らない?
新しいツールチェーンだけを相手にするなら exports だけでも動きます。ただし古いbundlerやツールは今も main を見ることがあるので、両方書いておくのが無難です。main はCJSの dist/index.cjs を指しておきます。
Q. tsupじゃないとESM/CJS両対応はできない?
できますが手間が増えます。tsc を2回(ESM用とCJS用)走らせて出力を分け、型定義を別途出す構成になります。小さなライブラリならtsup一択でいい、というのが僕の結論です。大規模ならRollupやunbuildも選択肢です。
Q. scope付き(@acme/...)パッケージの公開で気をつけることは?
初回公開は npm publish --access public を付けないとprivate扱いで弾かれます(有料プランがない場合)。2回目以降は不要ですが、初回だけ忘れがちなのでCIのコマンドに入れてあります。
Q. npm pack --dry-run と npm pack の違いは?
--dry-run は「送られる予定のファイル一覧」を表示するだけで .tgz を作りません。npm pack(フラグなし)は実際に .tgz を生成します。中身を一覧で見たいだけなら --dry-run、別ディレクトリでインストール検証したいなら実体を作る、と使い分けます。
Q. 公開したパッケージを取り消せる?
72時間以内なら npm unpublish で消せますが、依存している人がいると壊すので非推奨です。基本は「消さずにバージョンを上げて出し直す」。だからこそ公開前の npm pack 確認が効きます。
実際に試した結果
この記事のコードは、Windowsの一時ディレクトリで npm install、npm test、npm run build、npm pack --dry-run、ESM/CJSの node -e 確認まで通しました。dist に index.js・index.cjs・index.d.ts の3つが出て、tarballにはソースもテストも入っていない状態を確認できています。
冒頭の ERR_REQUIRE_ESM 以来、僕の公開前の合言葉は「distを見たか、tarballを見たか、利用者と同じ入口で読んだか」になりました。この3つを preflight.mjs に固めてからは、READMEだけ更新されてdistが古い、不要ファイルが混入する、ESMでしか読めない、といった事故がほぼ消えました。Claude Codeに頼むときも、完了条件に npm pack の出力説明を必ず入れます。公開ボタンに相当する判断だけ自分の手元に残す。これでnpm公開はだいぶ怖くなくなります。
テンプレートやレビュー観点をまとめて手元に置きたいなら ClaudeCodeLabの教材一覧 が近道です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。