はじめに#
TypeScriptプロジェクトが大規模になるにつれ、コードを適切なモジュール構造で整理することが重要になります。「ファイル間で関数や型をどう共有すればいいのか」「import typeとimportの違いは何か」「パスエイリアスはどう設定するのか」といった疑問を持つ方も多いのではないでしょうか。
本記事では、TypeScriptのモジュールシステムについて、ESModulesの基本構文から型のみのインポート、モジュール解決の仕組み、パスエイリアスの設定まで、実践的な内容を体系的に解説します。
本記事を読むことで、以下のことが理解できるようになります。
- ESModulesのexport・import構文の使い方
- 名前付きエクスポートとデフォルトエクスポートの使い分け
- import typeを使った型のみのインポート
- TypeScriptのモジュール解決の仕組み
- pathsによるパスエイリアスの設定方法
実行環境・前提条件#
前提知識#
- TypeScriptの基本的な型定義の理解
- JavaScriptのimport/export構文の基礎知識
- tsconfig.jsonの基本的な設定方法
動作確認環境#
本記事のサンプルコードは、以下の環境で動作確認しています。
- Node.js 22.x
- TypeScript 5.7
- VS Code + TypeScript拡張機能
オンラインで試す場合は、TypeScript PlaygroundまたはStackBlitzを利用できます。
ESModulesの基本#
モジュールとは#
TypeScriptにおいて、トップレベルにimportまたはexport文を含むファイルは「モジュール」として扱われます。一方、これらの文を含まないファイルは「スクリプト」として扱われ、その内容はグローバルスコープで利用可能になります。
1
2
3
4
|
// このファイルはモジュールとして扱われる(exportが含まれているため)
export const VERSION = "1.0.0";
// 他のモジュールからインポート可能
|
モジュールは独自のスコープ内で実行されるため、モジュール内で宣言された変数、関数、クラスなどは、明示的にエクスポートしない限り外部からアクセスできません。
graph LR
subgraph モジュールA
A1[export function]
A2[内部関数]
end
subgraph モジュールB
B1[import]
B2[使用]
end
A1 -->|公開| B1
B1 --> B2
A2 -.->|アクセス不可| B1ファイルをモジュールとして扱う#
import/exportを含まないファイルでも、モジュールとして扱いたい場合があります。その場合は、空のexport文を追加します。
1
2
3
4
5
|
// utils.ts
// 何もエクスポートしないが、モジュールとして扱いたい場合
const internalValue = 42;
export {};
|
名前付きエクスポート#
基本構文#
名前付きエクスポートは、複数の値や型を個別にエクスポートする方法です。エクスポートされた名前は、インポート時にも同じ名前で参照します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// math.ts
export const PI = 3.14159;
export const E = 2.71828;
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export interface Point {
x: number;
y: number;
}
export type Vector = [number, number, number];
|
インポート方法#
名前付きエクスポートをインポートする際は、波括弧{}を使用します。
1
2
3
4
5
6
7
|
// app.ts
import { PI, add, Point } from "./math.js";
const result = add(PI, 1);
console.log(result); // 4.14159
const origin: Point = { x: 0, y: 0 };
|
名前の変更(エイリアス)#
インポート時に名前を変更したい場合は、asキーワードを使用します。
1
2
3
4
5
|
// 名前の衝突を避けるためにエイリアスを使用
import { add as mathAdd, PI as MathPI } from "./math.js";
const add = (a: string, b: string) => a + b; // 別のadd関数を定義可能
const result = mathAdd(MathPI, 2);
|
エクスポート時にも名前を変更できます。
1
2
3
4
5
|
// internal.ts
const internalPI = 3.14159;
const internalAdd = (a: number, b: number) => a + b;
export { internalPI as PI, internalAdd as add };
|
まとめてエクスポート#
宣言と別にエクスポートをまとめて記述することもできます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// geometry.ts
const PI = 3.14159;
function calculateArea(radius: number): number {
return PI * radius * radius;
}
function calculateCircumference(radius: number): number {
return 2 * PI * radius;
}
interface Circle {
center: { x: number; y: number };
radius: number;
}
// まとめてエクスポート
export { PI, calculateArea, calculateCircumference, Circle };
|
名前空間インポート#
モジュールのすべてのエクスポートを一つのオブジェクトとしてインポートできます。
1
2
3
4
5
6
7
|
// app.ts
import * as math from "./math.js";
console.log(math.PI); // 3.14159
console.log(math.add(1, 2)); // 3
const point: math.Point = { x: 10, y: 20 };
|
デフォルトエクスポート#
基本構文#
デフォルトエクスポートは、モジュールごとに一つだけ設定できる特別なエクスポートです。インポート時に任意の名前を付けられるのが特徴です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// logger.ts
export default class Logger {
private prefix: string;
constructor(prefix: string) {
this.prefix = prefix;
}
log(message: string): void {
console.log(`[${this.prefix}] ${message}`);
}
error(message: string): void {
console.error(`[${this.prefix}] ERROR: ${message}`);
}
}
|
インポート方法#
デフォルトエクスポートは波括弧なしでインポートし、任意の名前を付けられます。
1
2
3
4
5
6
7
|
// app.ts
import Logger from "./logger.js";
// または任意の名前で
import MyLogger from "./logger.js";
const logger = new Logger("App");
logger.log("Application started");
|
名前付きエクスポートとの併用#
デフォルトエクスポートと名前付きエクスポートを同じモジュールで使用できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
// api.ts
export interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
export interface ApiError {
code: string;
message: string;
}
export default class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${endpoint}`);
const data = await response.json();
return {
data,
status: response.status,
message: response.statusText,
};
}
}
|
1
2
3
4
5
6
7
8
|
// app.ts
import ApiClient, { ApiResponse, ApiError } from "./api.js";
const client = new ApiClient("https://api.example.com");
async function fetchUsers(): Promise<ApiResponse<User[]>> {
return client.get<User[]>("/users");
}
|
名前付きエクスポート vs デフォルトエクスポート#
どちらを使用するかは、プロジェクトやチームの方針によりますが、一般的な使い分けの指針があります。
| 観点 |
名前付きエクスポート |
デフォルトエクスポート |
| 複数のエクスポート |
適している |
1つのみ |
| 名前の一貫性 |
インポート時も同じ名前 |
インポート時に任意の名前 |
| ツリーシェイキング |
しやすい |
しにくい場合がある |
| リファクタリング |
名前変更が追跡しやすい |
追跡しにくい |
| 推奨ケース |
ユーティリティ関数群、型定義 |
1ファイル1クラスの場合 |
多くのTypeScriptプロジェクトでは、名前付きエクスポートを優先して使用することが推奨されています。理由として、IDE補完の精度向上、リファクタリングのしやすさ、明示的な依存関係の把握があります。
型のみのインポート・エクスポート#
import type構文#
TypeScript 3.8で導入されたimport typeは、型情報のみをインポートすることを明示する構文です。この構文を使用すると、コンパイル後のJavaScriptから該当のインポート文が完全に削除されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// types.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface Post {
id: number;
title: string;
content: string;
author: User;
}
export const DEFAULT_USER: User = {
id: 0,
name: "Guest",
email: "guest@example.com",
};
|
1
2
3
4
5
6
7
8
9
10
11
12
|
// app.ts
// 型のみをインポート(コンパイル後のJSには残らない)
import type { User, Post } from "./types.js";
// 値もインポートする場合は通常のimport
import { DEFAULT_USER } from "./types.js";
function greetUser(user: User): string {
return `Hello, ${user.name}!`;
}
console.log(greetUser(DEFAULT_USER));
|
インライン型インポート#
TypeScript 4.5以降では、個別のインポートにtype修飾子を付けられます。
1
2
3
4
5
6
7
8
|
// 値と型を1つのimport文で区別してインポート
import { DEFAULT_USER, type User, type Post } from "./types.js";
function processUser(user: User): void {
console.log(`Processing user: ${user.name}`);
}
processUser(DEFAULT_USER);
|
export type構文#
エクスポート時にも型のみであることを明示できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// models/user.ts
interface User {
id: number;
name: string;
}
interface UserCredentials {
email: string;
password: string;
}
// 型のみをエクスポート
export type { User, UserCredentials };
|
1
2
3
4
|
// models/index.ts(バレルファイル)
// 再エクスポート時も型のみであることを明示
export type { User, UserCredentials } from "./user.js";
export type { Post, Comment } from "./post.js";
|
import typeを使うべき理由#
import typeを使用するメリットは以下の通りです。
-
バンドルサイズの最適化: 型のみのインポートは出力から完全に削除されるため、不要なコードが含まれません
-
循環依存の回避: 型のみの参照は実行時に影響しないため、循環依存の問題を軽減できます
-
意図の明確化: 型として使用することが明示されるため、コードの可読性が向上します
-
トランスパイラとの互換性: Babel、swc、esbuildなどの非TypeScriptトランスパイラが、削除すべきインポートを正確に識別できます
verbatimModuleSyntaxオプション#
TypeScript 5.0以降では、verbatimModuleSyntaxオプションにより、型のみのインポートを強制できます。
1
2
3
4
5
|
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
|
このオプションを有効にすると、型のみで使用されるインポートにtype修飾子がない場合、エラーが発生します。
1
2
3
4
5
6
7
|
// verbatimModuleSyntax: true の場合
// エラー: 'User'は型としてのみ使用されているため、import typeを使用する必要がある
import { User } from "./types.js";
// 正しい書き方
import type { User } from "./types.js";
|
再エクスポート#
基本的な再エクスポート#
モジュールをインポートして、そのまま再エクスポートできます。これは、複数のモジュールを1つのエントリポイントにまとめる際に便利です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// models/user.ts
export interface User {
id: number;
name: string;
}
// models/post.ts
export interface Post {
id: number;
title: string;
}
// models/index.ts(バレルファイル)
export { User } from "./user.js";
export { Post } from "./post.js";
|
1
2
3
|
// app.ts
// 1つのパスからまとめてインポートできる
import { User, Post } from "./models/index.js";
|
すべてを再エクスポート#
export *構文で、モジュールのすべての名前付きエクスポートを再エクスポートできます。
1
2
3
4
|
// models/index.ts
export * from "./user.js";
export * from "./post.js";
export * from "./comment.js";
|
デフォルトエクスポートの再エクスポート#
デフォルトエクスポートを名前付きエクスポートとして再エクスポートできます。
1
2
3
4
5
6
7
8
|
// services/auth.ts
export default class AuthService {
// ...
}
// services/index.ts
export { default as AuthService } from "./auth.js";
export { default as UserService } from "./user.js";
|
モジュール解決#
moduleResolutionオプション#
TypeScriptがモジュールを解決する方法は、tsconfig.jsonのmoduleResolutionオプションで制御します。
1
2
3
4
5
|
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}
|
主なオプションは以下の通りです。
| オプション |
用途 |
特徴 |
node16 / nodenext |
Node.js v12以降 |
ESModulesとCommonJSの両方をサポート |
bundler |
Vite、webpack、esbuild等 |
バンドラー向け、拡張子省略可能 |
node10 |
レガシーNode.js |
CommonJSのみ、非推奨 |
node16 / nodenextの特徴#
Node.js v12以降向けの設定で、ESModulesとCommonJSの両方のモジュール形式をサポートします。
1
2
3
4
5
6
|
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext"
}
}
|
このモードでは、ファイル拡張子を明示的に指定する必要があります。
1
2
3
|
// node16/nodenext では拡張子が必須
import { helper } from "./utils.js"; // OK
import { helper } from "./utils"; // エラー
|
bundlerの特徴#
Vite、webpack、esbuildなどのバンドラーを使用するプロジェクト向けの設定です。
1
2
3
4
5
6
|
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
}
}
|
拡張子の省略やディレクトリインポートが可能です。
1
2
3
|
// bundler モードでは拡張子省略可能
import { helper } from "./utils"; // OK
import { config } from "./config"; // ./config/index.ts を解決
|
package.jsonのexportsフィールド#
node16、nodenext、bundlerモードでは、package.jsonのexportsフィールドを解釈します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
{
"name": "my-library",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
}
}
|
パスエイリアス#
pathsオプションの設定#
pathsオプションを使用すると、インポートパスにエイリアスを設定できます。長い相対パスを短くしたり、プロジェクト構造を抽象化したりするのに便利です。
1
2
3
4
5
6
7
8
9
10
11
|
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"]
}
}
}
|
使用例#
パスエイリアスを設定すると、深いディレクトリからでも簡潔なパスでインポートできます。
1
2
3
4
5
6
7
8
9
|
// 設定前(長い相対パス)
import { Button } from "../../../components/ui/Button";
import { formatDate } from "../../../utils/date";
import type { User } from "../../../types/user";
// 設定後(パスエイリアス使用)
import { Button } from "@components/ui/Button";
import { formatDate } from "@utils/date";
import type { User } from "@types/user";
|
注意点#
pathsオプションには重要な注意点があります。
-
出力に影響しない: TypeScriptコンパイラは出力ファイルのパスを変換しません。バンドラーや追加のツールが必要です
-
バンドラーとの連携が必要: Vite、webpackなどのバンドラーでも同じエイリアスを設定する必要があります
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// vite.config.ts
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@components": path.resolve(__dirname, "src/components"),
"@utils": path.resolve(__dirname, "src/utils"),
},
},
});
|
1
2
3
4
5
6
7
8
9
10
|
// webpack.config.js
module.exports = {
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@components": path.resolve(__dirname, "src/components"),
"@utils": path.resolve(__dirname, "src/utils"),
},
},
};
|
package.jsonのimportsフィールド#
Node.js v12.19.0以降では、package.jsonのimportsフィールドでパスエイリアスを設定できます。この方法はpathsと異なり、ランタイムでも機能します。
1
2
3
4
5
6
7
|
{
"name": "my-app",
"imports": {
"#utils/*": "./src/utils/*",
"#components/*": "./src/components/*"
}
}
|
1
2
3
|
// importsフィールドを使用したインポート
import { formatDate } from "#utils/date.js";
import { Button } from "#components/Button.js";
|
ダイナミックインポート#
基本構文#
import()関数を使用すると、モジュールを動的にインポートできます。これにより、コードの遅延読み込みが可能になります。
1
2
3
4
5
6
7
|
async function loadModule() {
// 動的インポート(Promiseを返す)
const { add, multiply } = await import("./math.js");
console.log(add(1, 2)); // 3
console.log(multiply(3, 4)); // 12
}
|
条件付きインポート#
実行時の条件に基づいてモジュールをインポートできます。
1
2
3
4
5
6
7
8
9
|
async function loadTheme(isDark: boolean) {
if (isDark) {
const { darkTheme } = await import("./themes/dark.js");
return darkTheme;
} else {
const { lightTheme } = await import("./themes/light.js");
return lightTheme;
}
}
|
型のダイナミックインポート#
import()を型の位置で使用すると、モジュールの型情報を参照できます。これはimport文を書かずに型を参照したい場合に便利です。
1
2
3
4
5
6
7
8
9
10
|
// モジュールの型を取得
type MathModule = typeof import("./math.js");
// エクスポートされた型を取得
type Point = import("./geometry.js").Point;
// 関数の戻り値の型として使用
function createPoint(x: number, y: number): import("./geometry.js").Point {
return { x, y };
}
|
実践的なモジュール設計#
ディレクトリ構造の例#
中規模以上のTypeScriptプロジェクトでは、以下のようなディレクトリ構造が一般的です。
src/
├── index.ts # エントリポイント
├── types/ # 型定義
│ ├── index.ts
│ ├── user.ts
│ └── post.ts
├── utils/ # ユーティリティ関数
│ ├── index.ts
│ ├── date.ts
│ └── string.ts
├── services/ # ビジネスロジック
│ ├── index.ts
│ ├── auth.ts
│ └── api.ts
└── components/ # UIコンポーネント
├── index.ts
├── Button.tsx
└── Input.tsx
バレルファイルのパターン#
各ディレクトリにindex.ts(バレルファイル)を配置し、エクスポートを集約します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// types/user.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface UserCreateInput {
name: string;
email: string;
}
// types/post.ts
export interface Post {
id: number;
title: string;
content: string;
authorId: number;
}
// types/index.ts(バレルファイル)
export type { User, UserCreateInput } from "./user.js";
export type { Post } from "./post.js";
|
1
2
|
// 使用側
import type { User, Post } from "@/types";
|
循環依存の回避#
モジュール間の循環依存は、実行時エラーや予期しない動作の原因になります。以下のパターンで回避できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// 問題のある例(循環依存)
// user.ts
import { Post } from "./post.js"; // postがuserをインポートしている場合、循環依存
export interface User {
posts: Post[];
}
// 解決策1: 共通の型ファイルに分離
// types/common.ts
export interface User {
id: number;
name: string;
}
export interface Post {
id: number;
title: string;
authorId: number;
}
// 解決策2: 型のみのインポートを使用
// user.ts
import type { Post } from "./post.js"; // 型のみなら循環依存の影響を軽減
|
レイヤードアーキテクチャでのモジュール設計#
graph TB
subgraph Presentation
C[Components]
end
subgraph Application
S[Services]
end
subgraph Domain
T[Types/Models]
end
subgraph Infrastructure
R[Repositories]
A[API Clients]
end
C --> S
S --> T
S --> R
R --> T
R --> A各レイヤーは下位レイヤーにのみ依存するように設計します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// domain/types/user.ts - ドメイン層
export interface User {
id: number;
name: string;
email: string;
}
// infrastructure/api/userApi.ts - インフラ層
import type { User } from "@/domain/types/user.js";
export async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// application/services/userService.ts - アプリケーション層
import type { User } from "@/domain/types/user.js";
import { fetchUser } from "@/infrastructure/api/userApi.js";
export class UserService {
async getUser(id: number): Promise<User> {
return fetchUser(id);
}
}
|
まとめ#
本記事では、TypeScriptのモジュールシステムについて、ESModulesの基本構文から実践的なモジュール設計まで解説しました。
ポイントを整理すると以下の通りです。
- 名前付きエクスポートを優先的に使用し、リファクタリングのしやすさとIDEサポートを活かす
- import typeを活用して型のみのインポートを明示し、バンドルサイズの最適化と循環依存の回避を図る
- moduleResolutionはプロジェクトの実行環境に合わせて適切に設定する(Node.jsなら
nodenext、バンドラー使用ならbundler)
- pathsによるパスエイリアスを設定する際は、バンドラー側の設定も忘れずに行う
- バレルファイルを活用してエクスポートを整理し、明確な依存関係を維持する
TypeScriptのモジュールシステムを適切に活用することで、保守性が高く、スケーラブルなプロジェクト構造を実現できます。
参考リンク#