はじめに

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を使用するメリットは以下の通りです。

  1. バンドルサイズの最適化: 型のみのインポートは出力から完全に削除されるため、不要なコードが含まれません

  2. 循環依存の回避: 型のみの参照は実行時に影響しないため、循環依存の問題を軽減できます

  3. 意図の明確化: 型として使用することが明示されるため、コードの可読性が向上します

  4. トランスパイラとの互換性: 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.jsonmoduleResolutionオプションで制御します。

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フィールド

node16nodenextbundlerモードでは、package.jsonexportsフィールドを解釈します。

 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オプションには重要な注意点があります。

  1. 出力に影響しない: TypeScriptコンパイラは出力ファイルのパスを変換しません。バンドラーや追加のツールが必要です

  2. バンドラーとの連携が必要: 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.jsonimportsフィールドでパスエイリアスを設定できます。この方法は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のモジュールシステムを適切に活用することで、保守性が高く、スケーラブルなプロジェクト構造を実現できます。

参考リンク