10. モジュールと宣言ファイル

ESM と CJS の相互運用 / 実務で最も事故が多い領域

📅 作成: 2026-08-29 / 更新: 2026-08-29

この章の到達点

この章の内容

  1. import と export
  2. ESM と CJS
  3. .d.ts と @types
  4. 型定義がないライブラリ

10.1 import と export

基本形

// lib.ts
export const greet = (name: string) => `Hello, ${name}`;
export type User = { name: string };

// main.ts
import { greet, type User } from "./lib.ts";

import type — 型だけを取り込む

型は実行時に消えるため、型だけを import している場合、その行ごと消えます。明示すると意図が伝わり、消えることも保証されます。

import type { User } from "./types.ts";        // 型だけ。実行時には消える
import { greet, type User } from "./lib.ts";   // 値と型を混ぜる書き方

import type を使う理由は、循環参照と副作用の回避です。

型だけが必要なのに通常の import を書くと、そのモジュールが実行時に読み込まれます。読み込むだけで副作用があるモジュールや、循環参照しているモジュールでは問題になります。型だけを使うなら import type と書いてください。

default export は避ける

// 名前付き export(推奨)
export const greet = () => {};
import { greet } from "./lib.ts";

// default export
export default function () {}
import whateverName from "./lib.ts";     // 名前を自由に付けられてしまう
項目名前付きdefault
名前の統一強制される自由に付けられる
リネームの追従エディタが追える追えない
CJS との相互運用問題が少ない食い違いが起きる(10.2)

10.2 ESM と CJS

2つのモジュール方式がある

方式読み込み公開由来
ESMimportexportJavaScript の標準。現在の主流
CJSrequire()module.exportsNode.js が独自に導入したもの

どちらで動くかは package.json が決める

{
	"type": "module"      // ← ESM。省略すると CJS
}
package.json "type" の値を見る ESM "type": "module" import / export を使う 拡張子の省略ができない __dirname が使えない require が使えない CJS "type" なし(既定) require / module.exports を使う 拡張子を省略できる __dirname が使える require が使える 同じ .ts でも、この設定で使える機能が変わる
図 1 — package.json"type" が ESM と CJS を切り替える

実際の違い

// main-dirname.ts
console.log(typeof __dirname === "undefined" ? "__dirname は使えない" : "__dirname は使える");
console.log(typeof require === "undefined" ? "require は使えない" : "require は使える");
$ node main-dirname.ts               # package.json に type がない場合
__dirname は使える
require は使える

$ node main-dirname.ts               # "type": "module" の場合
__dirname は使えない
require は使えない

ESM では __dirname__filenamerequire が存在しません。

代わりに import.meta.dirname(Node 20.11 以降)を使います。

const dir = import.meta.dirname;      // ESM でのファイルの場所

よく出るエラーと原因

エラー原因対処
ERR_REQUIRE_ESMCJS から ESM を require した呼ぶ側を ESM にする("type": "module"
Cannot use import statement outside a moduleCJS のファイルで import を書いた同上
ERR_MODULE_NOT_FOUNDESM 拡張子を省略した"./lib.js" のように拡張子を書く
__dirname is not definedESM で __dirname を使ったimport.meta.dirname にする
x is not a function(実行時)default export の食い違い下記参照

拡張子の扱い

ESM ESM では拡張子の省略ができません。しかも書くのは出力後の拡張子です。

import { greet } from "./lib";        // ESM では動かない
import { greet } from "./lib.js";     // ← .ts を書いても .js と書く(出力後の名前)
import { greet } from "./lib.ts";     // Node で直接 .ts を実行する場合はこちら

ここが混乱の元です。

tsc でコンパイルして実行する場合、lib.tslib.js になります。import のパスは書き換えられないため、最初から .js と書く必要があります。

一方、Node の型ストリップtsx.ts を直接実行する場合は .ts と書きます。どちらの運用にするかを最初に決めてください。混ぜると必ず事故ります。

default export の食い違い

// CJS のライブラリ
module.exports = function foo() {};

// ESM から使う
import foo from "some-cjs-lib";       // 動く場合と動かない場合がある
import * as foo from "some-cjs-lib";  // foo.default になることがある

ESMCJS module.exportsexport default別の仕組みです。Node とバンドラで解釈が異なることもあります。

{
	"compilerOptions": {
		"esModuleInterop": true        // 相互運用を助ける(既定で有効なことが多い)
	}
}

「型は通るのに実行時に x is not a function になる」ときは、まずこの食い違いを疑ってください。

10.3 .d.ts と @types

.d.ts は型だけを書くファイル

// lib.d.ts — 実装はなく、形だけを宣言する
export declare function greet(name: string): string;
export declare const VERSION: string;

tsc.ts をコンパイルするとき、declaration: true を指定すると .d.ts も出力します。ライブラリを配布するときは、.js.d.ts をセットで配ります。

型定義がどこから来るか

場所確認方法
ライブラリ同梱package.json"types" または "typings" フィールド
@types/*node_modules/@types/ 配下
自分で書いたプロジェクト内の .d.ts

TypeScript 7 では types への明示が必要

TS7 02. 実行環境で触れたとおり、@types/* を入れるだけでは読み込まれません。

{
	"compilerOptions": {
		"types": ["node"]        // ← 使うものを列挙する
	}
}

types を書くと、書いたものだけが読まれます。

@types/node@types/jest の両方を使うなら ["node", "jest"] と書きます。1つだけ書くと、もう一方が読まれなくなります。

10.4 型定義がないライブラリ

まず @types を探す

$ npm i -D @types/ライブラリ名

見つからなければ、次の3つから選びます。

① 最小限の .d.ts を自分で書く

// types/legacy-lib.d.ts
declare module "legacy-lib" {
	export function doSomething(input: string): number;
}
{
	"compilerOptions": {
		"typeRoots": ["./node_modules/@types", "./types"]
	},
	"include": ["src/**/*.ts", "types/**/*.d.ts"]
}

使う関数だけ書けば十分です。全体を網羅する必要はありません。

② any として受け入れる(暫定)

// types/legacy-lib.d.ts
declare module "legacy-lib";      // すべて any になる

これは any の伝播を招きます。

そのライブラリから来る値がすべて any になり、下流の検査が無効化されます06)。使うなら受け口で unknown に変換してください。

import { doSomething } from "legacy-lib";

// any のまま使わず、受け口で unknown にする
const result: unknown = doSomething("x");
if (typeof result === "number") {
	console.log(result.toFixed(2));
}

③ ラッパーを1枚かぶせる

実務で最も勧められる形です。型のない世界を1ファイルに閉じ込めます。

// src/legacy-wrapper.ts — ここだけが型のない世界に触れる
import * as legacy from "legacy-lib";

export function doSomething(input: string): number {
	const r: unknown = (legacy as { doSomething(s: string): unknown }).doSomething(input);
	if (typeof r !== "number") {
		throw new Error(`legacy-lib が想定外の値を返しました: ${String(r)}`);
	}
	return r;
}
ラッパーなし — any が全体に広がる 型のないライブラリ any service.ts handler.ts main.ts 全部 any ラッパーあり — 型のない世界が1ファイルで止まる 型のないライブラリ any wrapper.ts unknown で受けて 検証して返す handler.ts main.ts 型が付く ライブラリを差し替えるときも、直すのは wrapper.ts だけで済む
図 2 — ラッパーを1枚かぶせると、型のない世界がそこで止まる

他のファイルは legacy-wrapper.ts だけを使います。ライブラリを差し替えるときも、直すのはこのファイルだけです。

手段手間安全性使いどころ
@types を入れるあるなら必ずこれ
最小の .d.ts使う範囲が狭いとき
declare module だけ暫定。受け口で unknown にする
ラッパー実務で最も勧められる