Tips & Tricks (更新: 2026/6/7)

npmパッケージ公開でハマる順番に、tsupでESM/CJS両対応する手順

npm publishでESM/CJS両対応のパッケージを公開する手順。package.jsonのexports、tsupビルド、型定義、npm packの中身確認、CI自動公開まで実例で。

npmパッケージ公開でハマる順番に、tsupでESM/CJS両対応する手順

初めて自作のnpmパッケージを公開した日、僕は得意げに npm publish を叩きました。緑のログが流れて「公開成功」。やった、と。

その3分後、別のプロジェクトで npm install して require した瞬間、Error [ERR_REQUIRE_ESM] が出ました。ESMでしか読めないパッケージを、CJSのコードから呼んでしまったんですね。しかも公開済みなので取り消せない。慌ててバージョンを上げて出し直しました。

このとき痛感したのは、**npm公開でハマるのは「コードの中身」じゃなくて「公開物の形」**だということ。package.jsonexports をどう書くか、ビルドでESMとCJSの両方を吐くか、型定義(.d.ts)を同梱するか、npm pack で実際に何が送られるか。ここを順番に押さえないと、インストールはできるのに使えないパッケージが出来上がります。

この記事では、TypeScript製の小さな文字列ユーティリティを題材に、その順番をなぞります。コードはWindowsの一時ディレクトリで npm install から npm pack まで通したものです。

この記事の要点

  • npm公開で詰まるのはコードより「公開物の形」。package.jsonexports / 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-kitscope付き公開は初回に --access public が要る
対応ランタイムNode.js / TypeScriptESM import とCJS require の両方で読めるか
ビルド形式tsupでESM・CJS・型定義を生成main / module / types / exports は連動する
公開物dist だけソースやテストをtarballに入れない
検証Vitest、npm pack --dry-run、distのimport確認公開前に中身を人間が見る

特に type: "module"mainexportstypes は互いに関係しています。あとから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 の効き具合、READMELICENSE、型定義が意図どおり入っているかをここで確認します。

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では byteLengthtruncate でdescriptionやカードの長さを機械的に揃えられます。

semverでバージョンを機械的に上げる

公開後の更新は感覚でやると荒れます。npmはsemver(セマンティックバージョニング)が前提なので、major.minor.patch を意味で決めます。

変更上げる桁判断の目安
互換を壊す変更major1.4.2 → 2.0.0関数名変更、引数削除、戻り値の型変更
後方互換の機能追加minor1.4.2 → 1.5.0新しい関数を足した
後方互換の修正patch1.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-runnpm pack の違いは? --dry-run は「送られる予定のファイル一覧」を表示するだけで .tgz を作りません。npm pack(フラグなし)は実際に .tgz を生成します。中身を一覧で見たいだけなら --dry-run、別ディレクトリでインストール検証したいなら実体を作る、と使い分けます。

Q. 公開したパッケージを取り消せる? 72時間以内なら npm unpublish で消せますが、依存している人がいると壊すので非推奨です。基本は「消さずにバージョンを上げて出し直す」。だからこそ公開前の npm pack 確認が効きます。

実際に試した結果

この記事のコードは、Windowsの一時ディレクトリで npm installnpm testnpm run buildnpm pack --dry-run、ESM/CJSの node -e 確認まで通しました。distindex.jsindex.cjsindex.d.ts の3つが出て、tarballにはソースもテストも入っていない状態を確認できています。

冒頭の ERR_REQUIRE_ESM 以来、僕の公開前の合言葉は「distを見たか、tarballを見たか、利用者と同じ入口で読んだか」になりました。この3つを preflight.mjs に固めてからは、READMEだけ更新されてdistが古い、不要ファイルが混入する、ESMでしか読めない、といった事故がほぼ消えました。Claude Codeに頼むときも、完了条件に npm pack の出力説明を必ず入れます。公開ボタンに相当する判断だけ自分の手元に残す。これでnpm公開はだいぶ怖くなくなります。

テンプレートやレビュー観点をまとめて手元に置きたいなら ClaudeCodeLabの教材一覧 が近道です。

#Claude Code #npm #tsup #TypeScript #OSS
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。