JavaScript

TypeScriptのモジュールとクラスの違い|export/importの書き分けと7.0の変更点

TypeScriptで「モジュールとクラスはどう違うのか」がつかみにくいのは、両方ともコードをまとめる単位に見えるからです。実際の役割は重なりません。モジュールはファイル単位のスコープ境界で、クラスはその中に置く宣言です。しかもクラスは、宣言ひとつで「型」と「値」の両方を作ります。この記事では両者を分ける言語仕様上の基準から、exportimportの書き分け、型だけを公開するときの書き方、そして2026年7月8日にGA版が出たTypeScript 7.0で削除されたモジュール設定の移行までを扱います。

まとめ:判定はトップレベルのimport/export、設定は7.0で総入れ替え

ファイルにトップレベルのimportexport・トップレベルawaitのいずれかがあれば、そのファイルはモジュールです。1つも無ければスクリプトとして扱われ、宣言はグローバルスコープに置かれます。クラスは型と値を同時に作るためnewもできれば型注釈にも書けますが、interfaceと型エイリアスは型しか作らず、値として参照するとTS2693になります。

設定面の変更は大きめです。TypeScript 7.0(npmのlatestは2026年8月2日時点で7.0.2)ではmodule: amdumdsystemnonemoduleResolution: node10classic、そしてbaseUrlが削除され、moduleResolutionに指定できる値はnode16nodenextbundlerの3つだけになりました。旧構成のまま7.0へ上げるとビルドが通りません。以下では判定基準・記法・移行手順を順に見ていきます。

モジュールとクラスを分ける2つの基準(スコープ単位と宣言が作るもの)

モジュールの判定基準:トップレベルのimport/exportの有無

TypeScript公式ハンドブックのModulesは、判定条件をこう定めています。「any file containing a top-level import or export is considered a module」。裏返すと、トップレベルのimportexport宣言(およびトップレベルawait)を持たないファイルは「a script whose contents are available in the global scope」、つまりグローバルスコープを共有するスクリプトです。スクリプト同士で同じ名前を宣言すると衝突します。

// globals.ts — import/exportが1つも無いのでスクリプト(グローバル)
const TAX_RATE = 0.1;

// legacy.ts — こちらもスクリプトなので同じグローバルスコープを共有する
const TAX_RATE = 0.08;
// error TS2451: Cannot redeclare block-scoped variable 'TAX_RATE'.

// どちらか一方に export {} を足せばモジュールになり、衝突は消える
export {};

末尾のexport {}は公式が案内している変換方法で、何も公開しないまま「このファイルはモジュールである」とだけ宣言します。逆にexport const TAX_RATE = 0.1;と書いたファイルはその時点でモジュールなので、同名の宣言が別ファイルにあっても衝突しません。グローバル前提の既存コードを整理するとき、最初に打つ手がexport {}です。

クラス宣言が同時に作る「型」と「値」

クラスがモジュールと混同されやすい理由は、宣言ひとつで2つの世界に名前を登録するからです。公式ハンドブックのDeclaration Mergingが示す表では、Classは型と値の両方を作り、Interfaceと型エイリアスは型だけを作ります。

export class User {
  constructor(public name: string) {}
}

// 型として使う
const a: User = new User('sato');

// 値として使う(クラス自身が実行時のオブジェクトとして存在する)
const ctor = User;

export interface Admin {
  name: string;
}

// const b = Admin;
// → error TS2693: 'Admin' only refers to a type, but is being used as a value here.

この二重性が、後述するimport typeの話につながります。クラスをimport typeで読み込むと型としてしか使えず、newできません。

宣言の種類ごとに作られるものの対応表

宣言 型を作る 値を作る
class 作る 作る
enum 作る 作る
interface 作る 作らない
type エイリアス 作る 作らない
namespace 作らない 作る(型のみを含む場合を除く)
function 作らない 作る
const / let 作らない 作る

ファイル単位のモジュールは、この表のどの行にも現れません。宣言ではなく、宣言を閉じ込める入れ物だからです。表にあるnamespaceはかつて「内部モジュール」と呼ばれていましたが、ES Modulesとは別物で、公式もES Modulesの利用を推奨しています。なおnamespaceが値を作るのは実装を含む場合で、interfaceしか入っていないnamespaceを値として参照するとTS2708になります。型の書き分けそのものを整理したい場合は型・型推論・型注釈の使い分けもあわせて確認してください。

exportとimportの書き分け(名前付き・default・再export)

名前付きexportとdefault exportの選択基準

実務では名前付きexportを既定にするのが無難です。理由は2つあります。第一に、importする側で名前が固定されるため、同じ関数が別名で呼ばれる事故が起きません。第二に、エディタの自動importとシンボルのリネームが正しく効きます。default exportはimport側が任意の名前を付けられるので、大きなコードベースでは呼び名が割れます。

// mathUtils.ts
export function add(a: number, b: number): number {
  return a + b;
}
export function sub(a: number, b: number): number {
  return a - b;
}

// main.ts
import { add, sub } from './mathUtils';
console.log(add(2, 3)); // 5

default exportを選ぶのは、そのファイルが公開する主体が1つだけだと決まっている場合に限ります。Next.jsのページコンポーネント(App Routerのpage.tsxなど)のように、フレームワーク側が既定exportを要求する構成がこれにあたります。

再exportとエイリアスによる公開範囲の設計

ディレクトリの入口となるindex.tsで再exportをまとめると、外部に見せるAPIを1ファイルで管理できます。型だけを再exportするときはexport typeを使うと、出力されるJavaScriptに余計な参照が残りません。

// index.ts で公開範囲をまとめる
export { add, sub } from './mathUtils';
export { User } from './user';
export type { Admin } from './user';

// 同名の関数を別モジュールから読むときはエイリアスで衝突を避ける
import { add as addNumbers } from './mathUtils';
import { add as addStrings } from './stringUtils';

再exportを重ねすぎると循環参照の温床になります。index.tsは1階層あたり1枚までに留め、モジュール同士がindex.ts経由でお互いを読む構造は作らないのが安全です。

CommonJSとの相互運用(export = と import = require)

CommonJS向けに1つの値だけをエクスポートする既存資産には、TypeScript独自のexport =があります。ES Modulesのexport defaultとは別物で、受け取る側もimport = require()で書きます。使えるのはCommonJS出力のときだけで、ESM側で書くとTS1203になります。

// tsconfig: { "compilerOptions": { "module": "commonjs" } } が前提
// legacy.ts
class Legacy {}
export = Legacy;

// consumer.ts
import Legacy = require('./legacy');
const l = new Legacy();

// module: nodenext のESMファイルで同じことを書くと
// error TS1203: Export assignment cannot be used when targeting ECMAScript modules.

新規コードでこの記法を選ぶ理由はありません。export =されたモジュールをデフォルトimportで受けるにはesModuleInteropが要りますが、7.0ではこのオプションをfalseにできなくなりました(esModuleInterop=false自体がTS5108で削除済み)。6.0以前で無効化していた環境ならTS2594が出ます。どちらにせよ出力形式はCommonJSに固定されるため、既存コードを読むために覚えておく記法という位置づけです。

型だけを公開するときの書き方(export interfaceとimport type)

export interfaceが出力JavaScriptに何も残さない理由

interfaceと型エイリアスは型しか作らないため、コンパイル後のJavaScriptには跡形も残りません。同じファイルにクラスとinterfaceを並べると、出力にはクラスだけが現れます。

// user.ts(入力)
export interface Admin { name: string; }
export class User { constructor(public name: string) {} }

// user.js(出力・抜粋)
export class User { constructor(name) { this.name = name; } }
// Admin に対応する出力は存在しない

この消え方が、型と値を混ぜてimportしたときの混乱を生みます。コンパイラは「この名前は型としてしか使われていないから消してよい」と推測して消しますが、その推測はファイル単位でしか働きません。

verbatimModuleSyntaxでimport typeが必須になる条件

推測に任せず、書いたとおりに出力させる設定がverbatimModuleSyntaxです。公式のTSConfigリファレンスによれば、type修飾子が付いたimport/exportは常に出力から削除され、修飾子が無いものは常に出力に残ります。結果として、interfaceや型エイリアスを読み込むときはimport typeを書かないとTS1484になります。クラスは値でもあるため、型注釈にしか使わない場合でも通常のimportで通ります。

// package.json に "type": "module" がある前提(または .mts ファイル)
// tsconfig.json
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "verbatimModuleSyntax": true
  }
}

// interface は型なので type 修飾子が必須
import type { Admin } from './user.js';
// type を外すと error TS1484: 'Admin' is a type and must be imported using a type-only import

// クラスは値でもあるので通常の import でよい
import { User } from './user.js';

CommonJS扱いのファイルでこの設定を有効にするとTS1287TS1295が出るため、"type": "module"の指定は必須です。既存の大規模コードに後から入れるとimport typeの追加が広範囲に及ぶので、モジュール単位で段階的に切り替えるほうが破綻しません。

クラスを作るべきでない場面とモジュール分割の判断基準

「関連する関数をまとめたいからクラスにする」は、TypeScriptでは選ばないでください。クラスは値を作る宣言なので、静的メソッドを1つ呼ぶだけでもクラス定義そのものが出力JavaScriptに残ります。関数を並べたモジュールなら、名前付きexportした関数を個別に参照でき、使われていない関数はバンドラの解析対象になります。言語側の方向も同じで、名前空間目的のmodule Foo {}構文はTypeScript 6.0でエラーに変わりました(詳細は後述のよくある質問)。

クラスを選ぶ判断基準は明確です。インスタンスごとに保持する状態があり、その状態を触る手続きが複数あるときです。データベース接続、キャッシュ、ゲームのエンティティなどが該当します。加えて、同じインターフェースで実装を差し替えたいときもクラスが向きます。implementsで契約を明示でき、型と値が同時に手に入るからです。

失敗しやすいのは、設定値の入れ物をクラスにするパターンです。new Config()で毎回生成すると設定が複数存在しうる状態になり、どのインスタンスが正なのかが読めなくなります。設定はモジュールのトップレベルでexport constし、型はinterfaceで与えてください。この形なら、モジュールが1度しか評価されない仕様がそのまま単一性の保証になります。

TypeScript 7.0で削除されたモジュール設定と移行手順

module: amd / umd / system / none の削除と移行先

2026年7月8日にGA版が公開されたTypeScript 7.0は、Go言語で書き直されたネイティブコンパイラです。npmのlatestは2026年8月2日時点で7.0.2です。7.0.0は公開されておらず、7.0.1はRC(7.0.1-rc)のみなので、安定版として最初に配布されたのが7.0.2になります。この移行に合わせて、TypeScript 6.0で非推奨になっていたモジュール関連オプションが削除されました。公式のリリース告知は「module: amd, umd, systemjs, none are no longer supported」と明記しています。

7.0で削除された設定 移行先 備考
module: amd esnext + バンドラ amd-module 指令は無視される
module: umd esnext + バンドラ UMDラッパはバンドラ側で生成
module: system esnext + バンドラ 告知の表記は systemjs
module: none esnext / preserve 単一グローバル出力は非対応
outFile(別オプション) 外部バンドラ 6.0で非推奨

設定値の綴りには注意が要ります。告知の文面は「systemjs」ですが、tsconfig.jsonに書く実際の値はsystemで、7.0ではTS5108として弾かれます。旧本文のようにAMDとUMDの使い分けを設計判断として検討する段階は終わりました。ブラウザ向けに1ファイルへまとめたい要件は、コンパイラではなくesbuildやRollupなどのバンドラ側で解決します。

moduleResolution node10・classic・baseUrlの書き換え

解決方式も同様です。moduleResolution: node(別名node10)とclassicが削除され、7.0で指定できるのはnode16nodenextbundlerの3つだけになりました。baseUrlも削除され、公式は「paths can be updated to be relative to the project root instead」と案内しています。pathsのマッピングをプロジェクトルート起点の相対パスに書き換えるという意味です。

// TypeScript 6.0までは動いていた設定
{
  "compilerOptions": {
    "module": "umd",
    "moduleResolution": "node10",
    "baseUrl": "./src",
    "paths": { "@app/*": ["app/*"] }
  }
}

// TypeScript 7.0で通る設定
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "paths": { "@app/*": ["./src/app/*"] }
  }
}

書き換えの具体的なエラーメッセージと対処はbaseUrl・moduleResolutionの非推奨対処にまとめてあります。コンパイラ本体の性能変化についてはGo製ネイティブコンパイラtsgoを参照してください。

ignoreDeprecationsで先送りできる範囲と期限

TypeScript 6.0のリリースノートは、"ignoreDeprecations": "6.0"を指定すれば非推奨エラーを抑止できるとしたうえで、「those options will be removed entirely in TypeScript 7.0」と続けています。つまりこのフラグは6.0の間だけ有効な猶予であり、7.0では該当オプションごと消えるため効果がありません。

6.0で警告が出ている段階が、実質的な移行期限です。tscbaseUrlmoduleResolutionの非推奨を報告し始めたら、抑止フラグを足すのではなく設定を書き換えます。7.0の告知は「TypeScript 7 does not yet expose a stable programmatic API」とも述べており、コンパイラを内部に組み込むツールを使っている場合は、そのツール側の7.0対応を待つ必要があります。

モジュールとクラスで起きやすいエラーと対処

循環参照でReferenceErrorになる条件(constとfunctionの差)

2つのモジュールが互いをimportすると、片方が評価される時点でもう片方の初期化が終わっていない状態が起こります。ここで結果を分けるのが宣言の種類です。function宣言はモジュールのインスタンス化時に初期化されるため巻き上げが効きますが、constに代入した関数式は一時的死角(TDZ)に入ります。

// a.ts
import { helperB } from './b';
export const helperA = () => helperB();

// b.ts
import { helperA } from './a';
export const helperB = () => 'ok';
export const VALUE = helperA();

// a.ts をエントリにすると b.ts が先に評価され、その時点で helperA は未初期化
// ReferenceError: Cannot access 'helperA' before initialization
// helperA を function 宣言に変えると巻き上げが効いて成功する

解決の型は決まっています。共通で使う値を第三のモジュールへ切り出し、a.tsとb.tsの双方がそちらを読む形に変えます。型だけの相互参照であればimport typeにするだけで循環が消えます。

クラスプロパティ未初期化によるTS2564とTypeError

strictPropertyInitializationstrictに含まれます)が有効なら、コンストラクタで代入されないプロパティはコンパイル時に検出されます。設定を切っている場合は実行時のTypeErrorになります。

class Car {
  model: string;
  // error TS2564: Property 'model' has no initializer and is not
  // definitely assigned in the constructor.
  constructor() {
    console.log(this.model.toUpperCase());
    // error TS2565: Property 'model' is used before being assigned.
  }
}

class SafeCar {
  model: string = 'Default Model';
  constructor() {
    console.log(this.model.toUpperCase()); // 'DEFAULT MODEL'
  }
}

初期値を持てない場合はmodel?: stringで任意プロパティにするか、コンストラクタ引数で受け取ります。model!: stringの非nullアサーションは検査を黙らせるだけなので、外部フレームワークが確実に注入する場面に限定してください。

型のみのimportを値として使ったときのTS1361

import typeで読み込んだ名前は型としてしか使えません。クラスをimport typeしてしまうとnewできず、TS1361になります。

import type { Admin, User } from './user.js';

const a: Admin = { name: 'sato' };   // OK(型として使用)
const u = new User('sato');
// error TS1361: 'User' cannot be used as a value because it was imported using 'import type'.

クラスをインスタンス化する側では、通常のimportに戻します。型注釈にしか使わないならimport typeのままでよく、その分だけ出力JavaScriptのimport文が減ります。

よくある質問

namespaceはモジュールの代わりに使えますか?

新規コードでは使わないでください。公式ハンドブックは「While not deprecated, the majority of the features in namespaces exist in ES Modules and we recommend you use that to align with JavaScript’s direction」と述べており、ES Modulesへ寄せる方針を明示しています。旧称「内部モジュール」に由来するmodule Foo {}という書き方は5.6以降エディタ上で非推奨として表示され、6.0でエラー(TS1540)へ昇格しました。ignoreDeprecationsでも抑止できないため、namespace Foo {}への書き換えが必須です。ただし文字列で名前を付けるdeclare module "pkg-name" {}はアンビエントなモジュール宣言で、この変更の対象外です。namespaceが今も残っている主な用途は、グローバル変数を持つ既存ライブラリの型定義を書く場面で、自前のアプリケーションコードをまとめるならファイル分割とexportで足ります。

export interfaceとexport typeの違いは何ですか?

export interface Foo {}は宣言と公開を同時に行う書き方で、export type { Foo }は既存の名前を型としてのみ再exportする書き方です。前者はinterfaceという宣言そのもの、後者はexportに付ける修飾子であり、比較の軸が違います。interfaceと型エイリアスはどちらも型しか作らないため、コンパイル後のJavaScriptには残りません。verbatimModuleSyntaxを有効にしている場合、型だけを再exportするときはexport typeの明示が必要になります。

nodenextではimportのパスに.js拡張子が必要ですか?

必要です。公式のModules Referenceは「Extensionless relative paths are not supported in import paths in Node.js…」と述べており、node16nodenextのESMでは相対パスに拡張子を書きます(requireでは省略できます)。書くのはソースの.tsではなく出力後の.jsです。TypeScriptは./mod.jsという指定に対して./mod.ts./mod.tsx./mod.d.ts./mod.jsの順で型情報を探すため、実行時とコンパイル時の両方が同じパスで解決できます。一方moduleResolution: bundlerではバンドラが補完するので拡張子を省けます。この記事では、拡張子を書いていない例がbundler前提、.js付きの例がnodenext前提です。

モジュールとクラスは同じファイルに混ぜてよいですか?

問題ありません。クラスはモジュールの中に置く要素であり、両者は排他ではないからです。ただし1ファイルに複数のクラスを詰めると、import側が必要としないクラスまで読み込むことになります。目安として、公開するクラスは1ファイルに1つ、そのクラスに紐づく型やヘルパーは同居させる構成にします。ディレクトリの入口にindex.tsを置いて再exportでまとめれば、外部から見えるAPIを絞りながらファイルは分けられます。

型定義ファイル(.d.ts)もモジュールとして扱われますか?

判定ルールは通常の.tsと同じです。トップレベルにimportexportがあればモジュール、無ければグローバルスコープに型を追加するスクリプトとして扱われます。この差は実務で効きます。グローバルな型を足すつもりで書いた.d.tsimportを1行入れた途端、その定義はモジュール内に閉じてグローバルから消えます。モジュール化した.d.tsからグローバルへ型を足したいときはdeclare global {}で囲んでください。この構文はモジュールかアンビエントモジュール宣言の直下にしか置けず、条件を外すとTS2669になります。

関連記事

資料請求

RELATED POSTS 関連記事