こんにちは、フロントエンドエンジニアの小張(@kobari41257)です。
PR TIMESのフロントエンドはpnpm workspaceを用いたモノレポ(以下prtimes-frontend)で開発されていますが、ほぼ全てのworkspace packageでpackage.jsonのtypeが指定されていませんでした。
そのため、Next.jsがCommonJSランタイムで実行されていたり、モジュール解決に不要なコストが発生するリスクを抱えていました。
今回全てのpackage.jsonに"type": "module" を指定し、上記のようなCommonJSに依存した挙動をなくし、モジュール解決の不要な実行コストを削減することができたので、その経緯についてご紹介します。
prtimes-frontendの構成や技術スタックについてはこちらをご覧ください。

背景
まず、今回の対応にあたり事前に調査したことなどを以下にまとめます。
CommonJSとES modulesについて
Node.jsを始めとするJSランタイムには大きく2つのモジュールシステムが存在しており、それがCommonJSとES modulesです。
両者では異なる記法が使われており、CommonJSではrequire() やmodule.exports 、ES modulesではimport やexport などが使用されています。
その他両者の特徴を簡単にまとめています。
- CommonJS
- 2025年2月時点で約80%のnpmパッケージがCommonJS形式で配布されている(参照:https://github.com/wooorm/npm-esm-vs-cjs )
- ES modulesと比べて実行速度が速い(参照:https://github.com/nodejs/node/issues/44186 )
- ES modules
- ECMAScript標準で仕様が定義されており、ブラウザとサーバー両方で動作する記法になっている
- 静的解析がしやすい記法のため、tree-shakingなどの最適化を行いやすい
トレンドの変化:Dual formats packageからESM-only packageへ
これまで、npmパッケージをCommonJSとES modules両方のランタイムで使えるようにするには、CommonJSだけを配布する形か、CommonJSとES modules両方の形式で配布する形(Dual formats package)が一般的でした。
これはCommonJSからES modulesをインポートする(以下require(esm) )ことができないという仕様によるものでした。
// esmodule.mjs
export const hello = () => {
console.log("Hello from ES Module!");
};
// commonjs.cjs
const { hello } = require('./esmodule.mjs'); // ❌ これはエラーになる
hello();ところがNode.js v22でrequire(esm) ができるようになったことで、Top-level awaitの例外を除き、ES modulesのみで配布されたパッケージ(ESM-only package)がCommonJSとES modules両方のランタイムで使えるようになりました。
Anthony Fu氏は以下の記事で、今後新規のパッケージはES modulesのみを配布することを推奨すると述べています。
I strongly recommend that all new packages be released as ESM-only
require(esm) でのdefault exportの扱い
Node.jsでは、ES modulesのdefault exportをCommonJSでrequire(esm) するとmodule namespace objectとしてインポートされるため、以下のように.default でアクセスする必要があります。
// point.mjs
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
// index.cjs
const point = require('./point.mjs');
console.log(point); // [Module: null prototype] { __esModule: true, default: [class Point] }
console.log(point.default); // [class Point](.defaultでアクセスする)代わりにas 'module.exports’ を使うことで.default を使わず直接アクセスするようにできます。
// point.mjs
export default class Point {
constructor(x, y) { this.x = x; this.y = y; }
}
export { Point as 'module.exports' }
// index.cjs
const point = require('./point.mjs');
console.log(point); // [class Point](そのままアクセスできる)つまり、default exportしているESM-only package側がas 'module.exports’を使っているかどうかによって、CommonJS側でインポートの仕方を変える必要があることになります。
CommonJSをランタイムに選択する場合この挙動に注意する必要がありますが、将来的にはTypeScriptやバンドラによって予期せぬ挙動は一定防ぐことができそうです。
ただ2025年5月時点ではTypeScriptやesbuildにはas 'module.exports’ に対応するためのissueが報告されており、ツールチェイン側の対応はこれからのようです。
package.jsonのtypeが未指定なことによる課題
上記を踏まえて、prtimes-frontendには以下の課題があることがわかりました。
1. Next.jsのbuild結果がCommonJSになる
package.jsonのtypeを指定せずにNext.js(v15.3.0時点)をoutput: 'standalone’ でbuildすると、エントリファイルである.next/standalone/server.js はCommonJSになります(逆に"type": "module" を指定した場合build結果はES modulesになります)。
// server.jsは以下のようになる
const path = require('path')
const dir = path.join(__dirname)
require('next')
const { startServer } = require('next/dist/server/lib/start-server')
startServer();つまりNext.jsのbuild結果がNode.jsサーバーで実行される際に、CommonJSで実行されている状態でした。
今後ESM-only packageが主流となった場合、CommonJSランタイムを選択することは上述したrequire(esm)のエッジケースなどを踏む可能性がある状態でした。
2. モジュール形式の検知に余計なコストがかかる
package.jsonのtypeを指定せずにES modulesで書かれたファイルをNode.jsで実行すると、以下のようなWarningが出ます。
(node:73059) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///server.ts is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.これはNode.jsが拡張子やpackage.jsonのtypeから判断できないファイルを実行する際、まずCommonJSとして実行し、途中でES modulesの記法を検知したらES modulesにfallbackして実行するという挙動があり、package.jsonのtypeを指定しないとパフォーマンス面で不利なためです。
幸いprtimes-frontendでこの影響を受ける箇所はローカル環境のみでしたが、今回の対応で合わせて解消することにしました。
対応内容
上記の課題を踏まえて以下の対応を実施しました。
- 全てのpackage.jsonに
"type": "module"を付与する .xo-config.jsや.eslintrc.jsの拡張子を.cjsにする.mts拡張子が付与されていたファイルを全て.ts拡張子にする- package.jsonの
main指定をやめてexports指定にする - ts-nodeの使用をやめて
node --experimental-strip-typesでサーバーを起動するようにする - Playwrightを実行するworkspaceにおいて、全てのimport文に
.ts拡張子をつける
その中で特徴的なものをいくつかご紹介します。
ts-nodeの使用をやめてnode --experimental-strip-types でサーバーを起動するようにする
prtimes-frontendではPlaywrightのテストを行っており、テスト対象となるReactアプリケーションを配信するHonoサーバーの起動にts-nodeを使っていました。
詳細については以下の記事をご覧ください。

しかし、package.jsonに"type": "module" を付与するとNode.js v22.14.0、ts-node v10.9.2でts-nodeが起動できなくなり、調査したところts-nodeがES modulesとして実行できない問題がissueとして報告されていました。
そこでNode.js v22.6.0で導入されたTypeScriptファイルを直接実行できる--experimental-strip-typesというオプションを使って、Honoサーバーを起動するようにしました。
$ node --experimental-strip-types server.tsPlaywrightを実行するworkspaceにおいて、全てのimport文に.ts拡張子をつける
"type": "module" を付与してPlaywright(v1.51.1)を実行すると、pnpm workspaceに対するimport文に拡張子がないというエラーが出るようになりました。
import {foo} from 'workspace-shared/utils'; // エラーになるError: Directory import '/workspace-test/node_modules/workspace-shared/utils' is not supported resolving ES modules imported from /workspace-test/test.tsこれはimportされる側のpackage.jsonにexports フィールドを指定することで回避できますが、exportsでBarrel Fileをエントリポイントに指定する必要がありデメリットも大きいので、今回は採用しませんでした。


代わりに下記画像のようにworkspace-test配下の全てのimport文に拡張子を付与して対応しました。

.ts 拡張子を許容するためallowImportingTsExtensions をtsconfigに追加しています。
// tsconfig.json
{
"compilerOptions": {
"allowImportingTsExtensions": true,
},
}
また、import文に拡張子がない場合ESLintでエラーを出したかったため以下のような設定を行いました。
// .xo-config.cjs
module.exports = {
settings: {
// import/extensionsがTypeScriptを解釈できるようにする
'import/resolver': {
typescript: true,
},
n: {
// .ts拡張子を自動付与する
typescriptExtensionMap: [
['', '.ts'],
['.js', '.ts'],
['.cjs', '.cts'],
['.mjs', '.mts'],
['.jsx', '.tsx'],
],
},
},
rules: {
// .ts拡張子がなければエラーにする(n/file-extension-in-importだけでは漏れるパターンがあるため追加)
'import/extensions': ['error', 'always'],
// .ts拡張子がなければ自動付与する
'n/file-extension-in-import': [
'error',
'never',
{ '.ts': 'always', '.json': 'always' },
],
},
};実はpnpm workspaceに対するimport文以外は、拡張子を省略してもPlaywrightを実行することができました。
しかし、特定のimport文だけ拡張子を強制するようなESLintの設定が思いつかなかったのと、今後.ts 拡張子をつけることが主流になる可能性もあり、試験的な意味も含めてPlaywrightの実行ディレクトリのみ全てのimport文に.ts 拡張子を強制するようにしました。
今回対応できなかったこと
1. Next.jsのbuild結果がCommonJSになる で触れたように、"type": "module" を指定するとNext.jsのbuild結果をES modulesにすることができますが、代わりにnext.config.jsをTypeScript化することができなくなりました。
現在Pull requestが立てられており、こちらの解決を待ってnext.config.ts に移行したいと思います。
まとめ
"type": "module" を付与することでCommonJSランタイムからES modulesランタイムに移行し、モジュール検知の不要な実行コストを削減することができました。
また対応の一環でメンテナンスが滞っていたts-nodeの使用を止めることができました。
今後も技術の動向をキャッチアップしながら、メンテナンス性の高いシステムを維持できるよう改善を続けていきます。
We are hiring!
フロントエンドエンジニアを含む各種ポジションでの採用を進めています!興味があればぜひご応募ください。


