デザインについての学習メモブログ

TypeScript入門 #5:実践編 – 実際のプロジェクトでの活用

記事内に広告が含まれています。

TypeScript入門 #5:実践編 – 実際のプロジェクトでの活用

第1回から第4回まで、TypeScriptの基礎から応用までを学んできました。

最終回となる今回は、実際のプロジェクトでTypeScriptをどう活用するかを学びます。

プロジェクト設定、実践的なコーディングパターン、そして本格的なアプリケーション開発の例を通して、実務で使えるスキルを身につけましょう。

プロジェクトのセットアップ

tsconfig.jsonの設定

TypeScriptプロジェクトの設定ファイルを詳しく見ていきましょう。

Bash
# プロジェクトの初期化
mkdir my-typescript-project
cd my-typescript-project
npm init -y
npm install --save-dev typescript @types/node ts-node

# tsconfig.jsonを生成
npx tsc --init

推奨のtsconfig.json設定

JSON
{
  "compilerOptions": {
    // 基本設定
    "target": "ES2020", // コンパイル先のJavaScriptバージョン
    "module": "nodenext", // モジュールシステム CommonJSとESModulesの両方に対応
    "lib": ["ES2020"], // 使用するライブラリ

    // 出力設定
    "outDir": "./dist", // コンパイル後のファイルの出力先
    "rootDir": "./src", // ソースファイルのルート
    "removeComments": true, // コメントを削除
    "sourceMap": true, // ソースマップを生成(デバッグ用)

    // 型チェックの厳密性
    "strict": true, // 全ての厳密な型チェックを有効化
    "noImplicitAny": true, // any型の暗黙的な使用を禁止
    "strictNullChecks": true, // nullとundefinedの厳密なチェック
    "strictFunctionTypes": true, // 関数型の厳密なチェック
    "strictBindCallApply": true, // bind, call, applyの厳密なチェック
    "strictPropertyInitialization": true, // クラスプロパティの初期化チェック
    "noImplicitThis": true, // thisの型が不明な場合にエラー
    "alwaysStrict": true, // strictモードで出力

    // 追加のチェック
    "noUnusedLocals": true, // 未使用のローカル変数を検出
    "noUnusedParameters": true, // 未使用のパラメータを検出
    "noImplicitReturns": true, // 関数の全てのパスで値を返すことを強制
    "noFallthroughCasesInSwitch": true, // switchのfallthroughを検出

    // モジュール解決
    "moduleResolution": "nodenext", // 最新のNode.js解決方法
    "esModuleInterop": true, // ESモジュールの相互運用性
    "allowSyntheticDefaultImports": true, // デフォルトエクスポートのないモジュールからのインポートを許可
    "resolveJsonModule": true, // JSONファイルのインポートを許可

    // その他
    "skipLibCheck": true, // 型定義ファイルのチェックをスキップ(ビルド高速化)
    "forceConsistentCasingInFileNames": true // ファイル名の大文字小文字を厳密にチェック
  },
  "include": ["src/**/*"], // コンパイル対象
  "exclude": ["node_modules", "dist"] // 除外するディレクトリ
}

moduleResolutionの設定について

第1回では`“moduleResolution”: “bundler”`を使いましたが、このNode.jsプロジェクトでは`“nodenext”`の方が適切です。

ここで違いを理解しましょう。

moduleResolutionの選択肢
設定値 説明使用場面
“bundler”Webpack、Viteなどのバンドラーを想定した解決方式フロントエンド開発、学習用プロジェクト
“nodenext”Node.jsの最新のモジュール解決方式(package.jsonの”type”を考慮)Node.jsのバックエンド開発
“node16”Node.js 16以降の解決方式Node.js 16以上を使う場合
“node”**非推奨**
(TypeScript 7.0で削除予定)
使用しない
bundler vs nodenext の違い
TypeScript
// 拡張子なしでインポート
import { myFunction } from './utils'
// bundler: OK(バンドラーが自動的に.tsや.jsを補完)
// nodenext: エラー!拡張子が必要

// nodenextの場合
import { myFunction } from './utils.js' 
// .tsファイルでも.jsと書く 
なぜ第1回でbundlerを使ったのか?
  • より寛容で初心者に優しい
  • 拡張子を省略できて書きやすい
  • 多くのプロジェクトで動作する
実際のプロジェクトでは
  • Node.jsバックエンド: “nodenext” を使う(より厳密)
  • フロントエンド(React/Vue等): “bundler” を使う
  • ライブラリ開発: プロジェクトに応じて選択
重要な注意

“module”: “commonjs”“moduleResolution”: “nodenext” を組み合わせる場合、“module”: “nodenext” に変更する方が一貫性があります。

JSON
{ 
  "compilerOptions": 
  { 
    "module": "nodenext",
    // moduleResolutionと揃える
    "moduleResolution": "nodenext" 
  } 
}

package.jsonのスクリプト設定

JSON
{
  "name": "my-typescript-project",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "ts-node src/index.ts",
    "watch": "tsc --watch",
    "clean": "rm -rf dist"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "ts-node": "^10.9.0",
    "typescript": "^5.3.0"
  }
}

プロジェクト構造(サンプル)

Bash
my-typescript-project/
├── src/
   ├── index.ts              # エントリーポイント
   ├── types/                # 型定義
      └── index.ts
   ├── models/               # データモデル
      ├── User.ts
      └── Product.ts
   ├── services/             # ビジネスロジック
      ├── UserService.ts
      └── ProductService.ts
   ├── utils/                # ユーティリティ関数
      └── validators.ts
   └── config/               # 設定
       └── database.ts
├── dist/                     # コンパイル後のファイル
├── node_modules/
├── package.json
└── tsconfig.json

実践的な型定義パターン

共通の型定義を一箇所にまとめる

TypeScript
// src/types/index.ts

// APIレスポンスの基本形
export type ApiResponse<T> = {
  success: boolean;
  data: T;
  message?: string;
  error?: ErrorDetail;
};

export type ErrorDetail = {
  code: string;
  message: string;
  field?: string;
};

// ページネーション
export type PaginatedResponse<T> = {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
};

// リクエストボディ
export type CreateRequest<T> = Omit<T, "id" | "createdAt" | "updatedAt">;
export type UpdateRequest<T> = Partial<CreateRequest<T>>;

// 日付の文字列表現
export type DateString = string; // ISO 8601形式

// IDの型
export type ID = number | string;

// ステータスの共通型
export type Status = "active" | "inactive" | "pending" | "deleted";

Omit(‘省く’)・・・既存の型から特定のプロパティを除外した新しい型を作成するユーティリティ型です。

Partial( ‘部分的’)・・・既存の型のすべてのプロパティをオプショナル(省略可能)にする型を作成するユーティリティ型です。

OmitとPartialは、「TypeScript入門 #3:関数と型、ジェネリクス入門」の「TypeScriptの組み込みユーティリティ型」で解説

日付の文字列表現(ISO 8601形式)・・・’2025-12-20T14:30:00+09:00′ // 日本時間(UTC+9)

ユーティリティ型の活用

以下のコードはTypeScriptの「組み込みユーティリティ型」を拡張して、より実用的にした「型」の定義をしています。

TypeScript
// src/types/utilities.ts

// ============================================
// DeepPartial<T> - ネストしたオブジェクトも全てオプショナルに
// ============================================
export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
/**
 * 動作の説明:
 * 
 * 1. [P in keyof T]? 
 *    → Tの全てのプロパティPに対して、オプショナル(?)にする
 * 
 * 2. T[P] extends object ? DeepPartial<T[P]> : T[P]
 *    → もしプロパティPの値がobjectなら、再帰的にDeepPartialを適用
 *    → そうでなければ、そのままの型を使う
 * 
 * 通常のPartial<T>との違い:
 * - Partial<T>: 1階層目だけオプショナル
 * - DeepPartial<T>: ネストした全階層をオプショナル
 */
 
// 使用例
type Config = {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  cache: {
    enabled: boolean;
    ttl: number;
  };
};

type DeepPartialConfig = DeepPartial<Config>;

// 実用例:設定の部分的な更新
function updateConfig(updates: DeepPartialConfig): void {
  // 深くネストした一部だけを更新できる
  console.log(updates);
}

updateConfig({
  database: {
    port: 5432  // hostやcredentialsは省略可能
  }
});




// ============================================
// DeepReadonly<T> - ネストしたオブジェクトも全て読み取り専用に
// ============================================
export type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
/**
 * 動作の説明:
 * 
 * 1. readonly [P in keyof T]
 *    → Tの全てのプロパティPをreadonly(読み取り専用)にする
 * 
 * 2. T[P] extends object ? DeepReadonly<T[P]> : T[P]
 *    → もしプロパティPの値がobjectなら、再帰的にDeepReadonlyを適用
 *    → そうでなければ、そのままの型を使う
 * 
 * 通常のReadonly<T>との違い:
 * - Readonly<T>: 1階層目だけ読み取り専用
 * - DeepReadonly<T>: ネストした全階層を読み取り専用
 */
 
// 使用例
const config: Config = {
  database: {
    host: "localhost",
    port: 5432,
    credentials: {
      username: "admin",
      password: "secret"
    }
  },
  cache: {
    enabled: true,
    ttl: 3600
  }
};

// 通常のReadonlyの場合
const readonlyConfig: Readonly<Config> = config;
// readonlyConfig.database = { ... };  // エラー!1階層目は変更不可
readonlyConfig.database.port = 3306;   // OK(ネストは変更可能)

// DeepReadonlyの場合
const deepReadonlyConfig: DeepReadonly<Config> = config;
// deepReadonlyConfig.database = { ... };      // エラー!
// deepReadonlyConfig.database.port = 3306;    // エラー!ネストも変更不可
// deepReadonlyConfig.database.credentials.password = "new";  // エラー!




// ============================================
// RequireKeys<T, K> - 特定のキーを必須にする
// ============================================
export type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
/**
 * 動作の説明:
 * 
 * 1. K extends keyof T
 *    → KはTのプロパティ名でなければならない(型安全性)
 * 
 * 2. Pick<T, K>
 *    → Tから、指定されたキーK(複数可)だけを取り出す
 * 
 * 3. Required<Pick<T, K>>
 *    → 取り出したプロパティを必須にする
 * 
 * 4. T & Required<Pick<T, K>>
 *    → 元の型Tと、必須にした型を交差(Intersection)させる
 *    → 結果として、指定したキーだけが必須になる
 */

// 使用例
type User = {
  id: number;
  name: string;
  email?: string;      // オプショナル
  age?: number;        // オプショナル
  phone?: string;      // オプショナル
};

// emailだけを必須にする
type UserWithEmail = RequireKeys<User, "email">;
// 結果:
// {
//   id: number;
//   name: string;
//   email: string;    // 必須になった
//   age?: number;
//   phone?: string;
// }

// 複数のキーを必須にする
type UserWithContact = RequireKeys<User, "email" | "phone">;
// 結果:
// {
//   id: number;
//   name: string;
//   email: string;    // 必須
//   age?: number;
//   phone: string;    // 必須
// }

// 実用例
function sendEmail(user: UserWithEmail): void {
  // emailが必須なので、安心して使える
  console.log(`Sending email to ${user.email}`);
}




// ============================================
// OmitStrict<T, K> - 特定のキーを除外(型安全版)
// ============================================

export type OmitStrict<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

/**
 * 動作の説明:
 * 
 * 1. K extends keyof T
 *    → KはTのプロパティ名でなければならない(通常のOmitにはこの制約がない)
 * 
 * 2. Exclude<keyof T, K>
 *    → Tの全プロパティ名から、Kを除外したUnion型を作る
 *    → 例: "id" | "name" | "email" から "id" を除外 → "name" | "email"
 * 
 * 3. Pick<T, Exclude<keyof T, K>>
 *    → 除外後のプロパティ名で、Tから選択する
 * 
 * 通常のOmit<T, K>との違い:
 * - Omit<T, K>: Kが存在しないキーでもエラーにならない(緩い)
 * - OmitStrict<T, K>: Kが存在しないキーだとエラー(厳密)
 */

// 使用例
type User = {
  id: number;
  name: string;
  email?: string;
  age?: number;
};

// idを除外
type UserWithoutId = OmitStrict<User, "id">;
// 結果: { name: string; email?: string; age?: number; }

// 通常のOmitとの違い
type Loose = Omit<User, "invalid">;        // エラーにならない(危険)
// type Strict = OmitStrict<User, "invalid">;  // エラー!(安全)

// 実用例:APIレスポンス用の型
type UserResponse = OmitStrict<User, "password">;  // 存在しないキーならエラー

Required・・・既存の型のすべてのオプショナルプロパティを必須に変換

Pick・・・既存の型から特定のプロパティだけを選択して新しい型を作成する

RequiredPickは、「TypeScript入門 #3:関数と型、ジェネリクス入門」の「TypeScriptの組み込みユーティリティ型」で解説

keyofは、「TypeScript入門 #3:関数と型、ジェネリクス入門」の「keyof演算子(型安全なプロパティアクセス)」で解説

DeepReadonly・・・DeepReadonlyは、TypeScriptで型のすべてのプロパティを再帰的(ネストされた階層すべて)に読み取り専用(readonly)にするユーティリティ型です。

Exclude・・・ユニオン型から特定の型を除外して新しい型を作成するユーティリティ型です。型のレベルで「フィルタリング」を行います。

実践例1:RESTful APIクライアント

実際のAPIと通信するクライアントを作成します。

先ずは、mainメソッドからコメントを参考にザックリと処理の流れを捉えてください。

TypeScript
// src/index.ts - 使用例
import { ApiClient } from "./services/ApiClient";
import { UserService } from "./services/UserService";

async function main() {
  // APIクライアントの初期化
  const apiClient = new ApiClient("https://api.example.com");
  const userService = new UserService(apiClient);

  // ユーザー一覧を取得
  const usersResponse = await userService.getUsers(1, 10);
  if (usersResponse.success) {
    console.log("ユーザー一覧:", usersResponse.data.items);
    console.log("合計:", usersResponse.data.total);
  } else {
    console.error("エラー:", usersResponse.error?.message);
  }

  // 新しいユーザーを作成
  const newUserResponse = await userService.createUser({
    name: "山田太郎",
    email: "yamada@example.com",
    role: "user",
  });

  if (newUserResponse.success) {
    console.log("ユーザーを作成しました:", newUserResponse.data);
  }

  // 認証トークンを設定
  apiClient.setAuthToken("your-auth-token");

  // ユーザーを更新
  const updateResponse = await userService.updateUser(1, {
    name: "山田花子",
  });

  if (updateResponse.success) {
    console.log("ユーザーを更新しました:", updateResponse.data);
  }
}

main();

以下の2つのクラスは次のソースを参照ください。

  • ApiClient: 主にAPIへのリクエストとレスポンス及び認証トークンを担当
  • UserService: ユーザーに関するCRUD処理を担当
TypeScript
// src/types/api.ts
export type User = {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "guest";
  createdAt: string;
  updatedAt: string;
};

export type Post = {
  id: number;
  title: string;
  content: string;
  authorId: number;
  published: boolean;
  createdAt: string;
  updatedAt: string;
};

export type CreateUserDto = Omit<User, "id" | "createdAt" | "updatedAt">;
export type UpdateUserDto = Partial<CreateUserDto>;

export type CreatePostDto = Omit<Post, "id" | "createdAt" | "updatedAt">;
export type UpdatePostDto = Partial<CreatePostDto>;
TypeScript
// src/services/ApiClient.ts
import type { ApiResponse } from "../types";

export class ApiClient {
  constructor(
    private baseURL: string,
    private defaultHeaders: Record<string, string> = {}
  ) {}

  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<ApiResponse<T>> {
    const url = `${this.baseURL}${endpoint}`;
    const headers = {
      "Content-Type": "application/json",
      ...this.defaultHeaders,
      ...options.headers,
    };

    try {
      const response = await fetch(url, { ...options, headers });
      const data = await response.json();

      if (!response.ok) {
        const errorMessage =
          data &&
          typeof data === "object" &&
          "message" in data &&
          typeof data.message === "string"
            ? data.message
            : "エラーが発生しました";

        return {
          success: false,
          data: data as T,
          error: {
            code: response.status.toString(),
            message: errorMessage,
          },
        };
      }

      return {
        success: true,
        data: data as T,
      };
    } catch (error) {
      return {
        success: false,
        data: {} as T,
        error: {
          code: "NETWORK_ERROR",
          message: error instanceof Error ? error.message : "不明なエラー",
        },
      };
    }
  }

  async get<T>(endpoint: string): Promise<ApiResponse<T>> {
    return this.request<T>(endpoint, { method: "GET" });
  }

  async post<T>(endpoint: string, body: unknown): Promise<ApiResponse<T>> {
    return this.request<T>(endpoint, {
      method: "POST",
      body: JSON.stringify(body),
    });
  }

  async put<T>(endpoint: string, body: unknown): Promise<ApiResponse<T>> {
    return this.request<T>(endpoint, {
      method: "PUT",
      body: JSON.stringify(body),
    });
  }

  async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
    return this.request<T>(endpoint, { method: "DELETE" });
  }

  // 認証トークンを設定
  setAuthToken(token: string): void {
    this.defaultHeaders["Authorization"] = `Bearer ${token}`;
  }
}
TypeScript
// src/services/UserService.ts
import type { User, CreateUserDto, UpdateUserDto } from "../types/api";
import type { ApiResponse, PaginatedResponse } from "../types";
import { ApiClient } from "./ApiClient";

export class UserService {
  constructor(private apiClient: ApiClient) {}

  async getUsers(
    page: number = 1,
    pageSize: number = 10
  ): Promise<ApiResponse<PaginatedResponse<User>>> {
    return this.apiClient.get<PaginatedResponse<User>>(
      `/users?page=${page}&pageSize=${pageSize}`
    );
  }

  async getUser(id: number): Promise<ApiResponse<User>> {
    return this.apiClient.get<User>(`/users/${id}`);
  }

  async createUser(data: CreateUserDto): Promise<ApiResponse<User>> {
    return this.apiClient.post<User>("/users", data);
  }

  async updateUser(
    id: number,
    data: UpdateUserDto
  ): Promise<ApiResponse<User>> {
    return this.apiClient.put<User>(`/users/${id}`, data);
  }

  async deleteUser(id: number): Promise<ApiResponse<void>> {
    return this.apiClient.delete<void>(`/users/${id}`);
  }

  async searchUsers(query: string): Promise<ApiResponse<User[]>> {
    return this.apiClient.get<User[]>(`/users/search?q=${query}`);
  }
}

ApiResponse, PaginatedResponseは、記事の始めに作成したサンプル「共通型定義パターンのソースコード」(src/types/index.ts)の型を使用

実践例2:Eコマースアプリケーション

より複雑なアプリケーションの例です。

商品検索から商品注文までのサンプルです。

TypeScript
// src/index.ts - Eコマースの使用例
import { ApiClient } from "./services/ApiClient";
import { ProductService } from "./services/ProductService";
import { OrderService } from "./services/OrderService";
import { ShoppingCart } from "./models/ShoppingCart";

async function ecommerceExample() {
  const apiClient = new ApiClient("https://api.shop.example.com");
  const productService = new ProductService(apiClient);
  const orderService = new OrderService(apiClient);

  // 商品を検索
  const searchResponse = await productService.searchProducts("ノートPC");
  if (!searchResponse.success) {
    console.error("検索エラー:", searchResponse.error?.message);
    return;
  }

  const products = searchResponse.data;
  console.log(`${products.length}件の商品が見つかりました`);

  // ショッピングカートを作成
  const cart = new ShoppingCart();

  // カートに商品を追加
  if (products.length > 0) {
    try {
      cart.addItem(products[0], 1);
      console.log("商品をカートに追加しました");
      console.log("カート内の商品数:", cart.itemCount);
      console.log("合計金額:", cart.total);
    } catch (error) {
      console.error("カート追加エラー:", error);
    }
  }

  // 注文を作成
  const shippingAddress = {
    street: "1-2-3 サンプル町",
    city: "東京",
    state: "東京都",
    zipCode: "100-0001",
    country: "Japan",
  };

  const orderResponse = await orderService.createOrder(
    1,
    cart.items,
    shippingAddress
  );

  if (orderResponse.success) {
    console.log("注文が完了しました:", orderResponse.data);
    cart.clear();
  } else {
    console.error("注文エラー:", orderResponse.error?.message);
  }
  
}

importされたクラスの主な役割に以下のようになります。扱うデータ型の定義も次のサンプルコードに書いています。

  • ApiClient :APIへのリクエスト(実践1のサンプルを再利用)
  • ProductService :商品情報の取得や検索
  • OrderService :オーダー関連処理(注文、更新、キャンセルなど)
  • ShoppingCart :ショッピングカートの操作
TypeScript
// src/types/ecommerce.ts

export type Product = {
  id: number;
  name: string;
  description: string;
  price: number;
  stock: number;
  categoryId: number;
  imageUrl: string;
  createdAt: string;
};

export type Category = {
  id: number;
  name: string;
  description: string;
};

export type CartItem = {
  productId: number;
  quantity: number;
  price: number;
};

export type Cart = {
  items: CartItem[];
  total: number;
};

export type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "cancelled";

export type Order = {
  id: number;
  userId: number;
  items: CartItem[];
  total: number;
  status: OrderStatus;
  shippingAddress: Address;
  createdAt: string;
  updatedAt: string;
};

export type Address = {
  street: string;
  city: string;
  state: string;
  zipCode: string;
  country: string;
};

export type PaymentMethod = "credit_card" | "debit_card" | "paypal" | "bank_transfer";

export type Payment = {
  orderId: number;
  amount: number;
  method: PaymentMethod;
  status: "pending" | "completed" | "failed";
  transactionId?: string;
  createdAt: string;
};
TypeScript
// src/models/ShoppingCart.ts
import type { Product, CartItem, Cart } from "../types/ecommerce";

export class ShoppingCart implements Cart {
  private _items: Map<number, CartItem> = new Map();

  get items(): CartItem[] {
    return Array.from(this._items.values());
  }

  get total(): number {
    return this.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  }

  get itemCount(): number {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }

  addItem(product: Product, quantity: number = 1): void {
    if (quantity <= 0) {
      throw new Error("数量は1以上である必要があります");
    }

    if (quantity > product.stock) {
      throw new Error(`在庫が不足しています。在庫数: ${product.stock}`);
    }

    const existingItem = this._items.get(product.id);

    if (existingItem) {
      const newQuantity = existingItem.quantity + quantity;
      if (newQuantity > product.stock) {
        throw new Error(`在庫が不足しています。在庫数: ${product.stock}`);
      }
      existingItem.quantity = newQuantity;
    } else {
      this._items.set(product.id, {
        productId: product.id,
        quantity,
        price: product.price,
      });
    }
  }

  removeItem(productId: number): void {
    this._items.delete(productId);
  }

  updateQuantity(productId: number, quantity: number): void {
    if (quantity <= 0) {
      this.removeItem(productId);
      return;
    }

    const item = this._items.get(productId);
    if (item) {
      item.quantity = quantity;
    }
  }

  clear(): void {
    this._items.clear();
  }

  hasItem(productId: number): boolean {
    return this._items.has(productId);
  }

  getItem(productId: number): CartItem | undefined {
    return this._items.get(productId);
  }

  toJSON(): Cart {
    return {
      items: this.items,
      total: this.total,
    };
  }
}
TypeScript
// src/services/OrderService.ts
import type { Order, OrderStatus, Address, CartItem } from "../types/ecommerce";
import type { ApiResponse } from "../types";
import { ApiClient } from "./ApiClient";

export class OrderService {
  constructor(private apiClient: ApiClient) {}

  async createOrder(
    userId: number,
    items: CartItem[],
    shippingAddress: Address
  ): Promise<ApiResponse<Order>> {
    return this.apiClient.post<Order>("/orders", {
      userId,
      items,
      shippingAddress,
    });
  }

  async getOrder(orderId: number): Promise<ApiResponse<Order>> {
    return this.apiClient.get<Order>(`/orders/${orderId}`);
  }

  async getUserOrders(userId: number): Promise<ApiResponse<Order[]>> {
    return this.apiClient.get<Order[]>(`/users/${userId}/orders`);
  }

  async updateOrderStatus(
    orderId: number,
    status: OrderStatus
  ): Promise<ApiResponse<Order>> {
    return this.apiClient.put<Order>(`/orders/${orderId}/status`, { status });
  }

  async cancelOrder(orderId: number): Promise<ApiResponse<Order>> {
    return this.updateOrderStatus(orderId, "cancelled");
  }

  calculateTotal(items: CartItem[]): number {
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}
TypeScript
// src/services/ProductService.ts
import type { Product, Category } from "../types/ecommerce";
import type { ApiResponse, PaginatedResponse } from "../types";
import { ApiClient } from "./ApiClient";

export class ProductService {
  constructor(private apiClient: ApiClient) {}

  async getProducts(
    page: number = 1,
    pageSize: number = 20,
    categoryId?: number
  ): Promise<ApiResponse<PaginatedResponse<Product>>> {
    let endpoint = `/products?page=${page}&pageSize=${pageSize}`;
    if (categoryId) {
      endpoint += `&categoryId=${categoryId}`;
    }
    return this.apiClient.get<PaginatedResponse<Product>>(endpoint);
  }

  async getProduct(id: number): Promise<ApiResponse<Product>> {
    return this.apiClient.get<Product>(`/products/${id}`);
  }

  async searchProducts(query: string): Promise<ApiResponse<Product[]>> {
    return this.apiClient.get<Product[]>(`/products/search?q=${query}`);
  }

  async getCategories(): Promise<ApiResponse<Category[]>> {
    return this.apiClient.get<Category[]>("/categories");
  }

  async getProductsByCategory(
    categoryId: number
  ): Promise<ApiResponse<Product[]>> {
    return this.apiClient.get<Product[]>(`/categories/${categoryId}/products`);
  }
}

ApiResponse, PaginatedResponseは、記事の始めに作成したサンプル「共通型定義パターンのソースコード」(src/types/index.ts)の型を使用

実践例3:型安全なイベント駆動システム

「イベントエミッターとは?」と思った方は#3の「実践例:型安全なイベントエミッター」に書いた「補足」を参照ください。イベントとハンドラーの関係イメージを簡単にまとめています。

TypeScript
// src/services/NotificationService.ts
import type { EventMap } from "../types/events";
import { TypedEventEmitter } from "./EventEmitter";

export class NotificationService {
  constructor(private eventEmitter: TypedEventEmitter) {
    this.setupListeners();
  }

  private setupListeners(): void {
    // ユーザー作成時の通知
    this.eventEmitter.on("user:created", async (data) => {
      console.log(`ウェルカムメールを送信: ${data.email}`);
      await this.sendEmail(data.email, "ようこそ!", "登録ありがとうございます");
    });

    // 注文作成時の通知
    this.eventEmitter.on("order:created", async (data) => {
      console.log(`注文確認メールを送信: 注文ID ${data.orderId}`);
      await this.sendOrderConfirmation(data.userId, data.orderId);
    });

    // 商品在庫低下時の通知
    this.eventEmitter.on("product:stock:low", (data) => {
      console.log(
        `アラート: 商品ID ${data.productId} の在庫が ${data.stock} です`
      );
      this.notifyAdmin(`商品ID ${data.productId} の在庫が少なくなっています`);
    });
  }

  private async sendEmail(
    to: string,
    subject: string,
    body: string
  ): Promise<void> {
    // メール送信のロジック
    console.log(`Email sent to ${to}: ${subject}`);
  }

  private async sendOrderConfirmation(
    userId: number,
    orderId: number
  ): Promise<void> {
    // 注文確認メールを送信
    console.log(`Order confirmation sent for order ${orderId}`);
  }

  private notifyAdmin(message: string): void {
    // 管理者に通知
    console.log(`Admin notification: ${message}`);
  }
}
TypeScript
// src/types/events.ts

// イベントのマッピング
export type EventMap = {
  "user:created": { userId: number; email: string };
  "user:updated": { userId: number; changes: Record<string, any> };
  "user:deleted": { userId: number };
  "order:created": { orderId: number; userId: number; total: number };
  "order:shipped": { orderId: number; trackingNumber: string };
  "product:stock:low": { productId: number; stock: number };
};

// イベント名の型
export type EventName = keyof EventMap;

// イベントハンドラーの型
export type EventHandler<T extends EventName> = (
  data: EventMap[T]
) => void | Promise<void>;
TypeScript
// src/services/EventEmitter.ts
import type { EventMap, EventName, EventHandler } from "../types/events";

export class TypedEventEmitter {
  private listeners: {
    [K in EventName]?: Set<EventHandler<K>>;
  } = {};

  // イベントリスナーを登録
  on<T extends EventName>(event: T, handler: EventHandler<T>): void {
    if (!this.listeners[event]) {
      this.listeners[event] = new Set();
    }
    this.listeners[event]!.add(handler as any);
  }

  // イベントリスナーを削除
  off<T extends EventName>(event: T, handler: EventHandler<T>): void {
    const handlers = this.listeners[event];
    if (handlers) {
      handlers.delete(handler as any);
    }
  }

  // イベントを発火(同期)
  emit<T extends EventName>(event: T, data: EventMap[T]): void {
    const handlers = this.listeners[event];
    if (handlers) {
      handlers.forEach((handler) => {
        handler(data);
      });
    }
  }

  // イベントを発火(非同期)
  async emitAsync<T extends EventName>(
    event: T,
    data: EventMap[T]
  ): Promise<void> {
    const handlers = this.listeners[event];
    if (handlers) {
      await Promise.all(
        Array.from(handlers).map((handler) => handler(data))
      );
    }
  }

  // 一度だけ実行されるリスナー
  once<T extends EventName>(event: T, handler: EventHandler<T>): void {
    const onceHandler: EventHandler<T> = (data) => {
      handler(data);
      this.off(event, onceHandler);
    };
    this.on(event, onceHandler);
  }

  // 全てのリスナーをクリア
  clear(): void {
    this.listeners = {};
  }
}

バリデーションとエラーハンドリング

ユーザーの入力チェックとそのエラーの扱いの事例サンプルになります。

TypeScript
// src/utils/validators.ts

export type ValidationResult =
  | { isValid: true }
  | { isValid: false; errors: string[] };

export class Validator {
  static email(value: string): ValidationResult {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(value)) {
      return { isValid: false, errors: ["無効なメールアドレスです"] };
    }
    return { isValid: true };
  }

  static required(value: any, fieldName: string): ValidationResult {
    if (value === null || value === undefined || value === "") {
      return { isValid: false, errors: [`${fieldName}は必須です`] };
    }
    return { isValid: true };
  }

  static minLength(
    value: string,
    min: number,
    fieldName: string
  ): ValidationResult {
    if (value.length < min) {
      return {
        isValid: false,
        errors: [`${fieldName}${min}文字以上である必要があります`],
      };
    }
    return { isValid: true };
  }

  static maxLength(
    value: string,
    max: number,
    fieldName: string
  ): ValidationResult {
    if (value.length > max) {
      return {
        isValid: false,
        errors: [`${fieldName}${max}文字以下である必要があります`],
      };
    }
    return { isValid: true };
  }

  static range(
    value: number,
    min: number,
    max: number,
    fieldName: string
  ): ValidationResult {
    if (value < min || value > max) {
      return {
        isValid: false,
        errors: [`${fieldName}${min}から${max}の範囲である必要があります`],
      };
    }
    return { isValid: true };
  }

  static combine(...results: ValidationResult[]): ValidationResult {
    const errors: string[] = [];
    for (const result of results) {
      if (!result.isValid) {
        errors.push(...result.errors);
      }
    }
    return errors.length > 0 ? { isValid: false, errors } : { isValid: true };
  }
}

// 使用例
export function validateUser(data: {
  name: string;
  email: string;
  age: number;
}): ValidationResult {
  return Validator.combine(
    Validator.required(data.name, "名前"),
    Validator.minLength(data.name, 2, "名前"),
    Validator.maxLength(data.name, 50, "名前"),
    Validator.required(data.email, "メールアドレス"),
    Validator.email(data.email),
    Validator.range(data.age, 0, 150, "年齢")
  );
}
TypeScript
// src/utils/errors.ts

export class AppError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode: number = 500
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

export class ValidationError extends AppError {
  constructor(message: string, public fields?: string[]) {
    super(message, "VALIDATION_ERROR", 400);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id: number | string) {
    super(`${resource} with id ${id} not found`, "NOT_FOUND", 404);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = "認証が必要です") {
    super(message, "UNAUTHORIZED", 401);
  }
}

export class ForbiddenError extends AppError {
  constructor(message: string = "権限がありません") {
    super(message, "FORBIDDEN", 403);
  }
}

// エラーハンドラー
export function handleError(error: unknown): void {
  if (error instanceof AppError) {
    console.error(`[${error.code}] ${error.message}`);
    // ログ記録やモニタリングサービスへの送信
  } else if (error instanceof Error) {
    console.error(`[UNKNOWN_ERROR] ${error.message}`);
  } else {
    console.error("[UNKNOWN_ERROR] An unknown error occurred");
  }
}

Error型は?

ErrorはJavaScript/TypeScriptの組み込み型(ビルトイン型)で、標準ライブラリに含まれているため、自分で定義する必要はありません。

TypeScriptでは: lib.es5.d.tsなどの型定義ファイルで以下のように定義されています。

他の組み込みError型

同様に、以下のような派生型もすべて組み込みで存在します:

  • TypeError
  • RangeError
  • SyntaxError
  • ReferenceError

これらもすべてインポートや定義なしで直接使用できます。

テストの書き方

TypeScript
// src/__tests__/ShoppingCart.test.ts

import { ShoppingCart } from "../models/ShoppingCart";
import type { Product } from "../types/ecommerce";

describe("ShoppingCart", () => {
  let cart: ShoppingCart;
  let mockProduct: Product;

  beforeEach(() => {
    cart = new ShoppingCart();
    mockProduct = {
      id: 1,
      name: "テスト商品",
      description: "テスト用の商品",
      price: 1000,
      stock: 10,
      categoryId: 1,
      imageUrl: "https://example.com/image.jpg",
      createdAt: new Date().toISOString(),
    };
  });

  test("商品をカートに追加できる", () => {
    cart.addItem(mockProduct, 2);
    expect(cart.itemCount).toBe(2);
    expect(cart.total).toBe(2000);
  });

  test("在庫を超える数量は追加できない", () => {
    expect(() => {
      cart.addItem(mockProduct, 20);
    }).toThrow("在庫が不足しています");
  });

  test("商品を削除できる", () => {
    cart.addItem(mockProduct, 1);
    cart.removeItem(mockProduct.id);
    expect(cart.itemCount).toBe(0);
  });

  test("数量を更新できる", () => {
    cart.addItem(mockProduct, 1);
    cart.updateQuantity(mockProduct.id, 3);
    expect(cart.itemCount).toBe(3);
  });

  test("カートをクリアできる", () => {
    cart.addItem(mockProduct, 1);
    cart.clear();
    expect(cart.itemCount).toBe(0);
  });
});

ベストプラクティス

1. 型を明示的に定義する

any型の使用を避け、具体的な型を定義することで、コンパイル時にエラーを検出し、コードの安全性と可読性を向上させます。

TypeScript
// ❌ 避けるべき
function processData(data: any) {
  return data.map((item: any) => item.value);
}

// ✅ 推奨
type DataItem = {
  id: number;
  value: string;
};

function processData(data: DataItem[]): string[] {
  return data.map((item) => item.value);
}

2. ユーティリティ型を活用する

TypeScript組み込みのユーティリティ型(Omit、Partialなど)を使用して、既存の型から新しい型を効率的に生成し、コードの重複を減らします。

TypeScript
// ✅ 推奨
type User = {
  id: number;
  name: string;
  email: string;
  password: string;
};

// パスワードを除外した型
type UserResponse = Omit<User, "password">;

// 更新用の部分的な型
type UserUpdateRequest = Partial<Omit<User, "id">>;

3. 型ガードを使う

実行時に値の型を確認し、TypeScriptコンパイラに型を正しく認識させることで、型安全性を保ちながら柔軟な処理が可能になります。

TypeScript
// 型ガード関数
function isUser(obj: any): obj is User {
  return (
    typeof obj === "object" &&
    obj !== null &&
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    typeof obj.email === "string"
  );
}

// 使用例
function processUser(data: unknown) {
  if (isUser(data)) {
    // この中では data は User 型として扱われる
    console.log(data.name);
  }
}

4. nullチェックを適切に行う

オプショナルチェーン(?.)とnull合体演算子(??)を使用して、nullやundefinedによるランタイムエラーを防ぎ、安全にデータを扱います。

TypeScript
// ✅ 推奨
function getUserName(user: User | null): string {
  return user?.name ?? "ゲスト";
}

// または
function getUserEmail(user: User | null): string | null {
  if (user === null) {
    return null;
  }
  return user.email;
}

オプショナルチェーン:
usernull または undefined の場合は undefined を返し、そうでなければ user.name にアクセスする

Null合体演算子:
左側が null または undefined の場合は右側(”ゲスト”)の値を返し、そうでなければ左側の値を返す

5. enumよりもUnion型を使う

数値enumの暗黙的な型変換を避け、文字列リテラル型のUnionを使用することで、より型安全で予測可能なコードを書けます。

TypeScript
// ❌ 避けるべき
enum Status {
  Active,
  Inactive,
  Pending,
}
// 問題:enumに定義されていない数値も代入できてしまう
let status: Status = 999;  // エラーにならない!
let status2: Status = -1;  // これもエラーにならない!
// 実行時に予期しない動作
function getStatusText(status: Status): string {
  switch (status) {
    case Status.Active: return "有効";
    case Status.Inactive: return "無効";
    case Status.Pending: return "保留中";
    default: return "不明";  // 999や-1はここに来る
  }
}


// ✅ 推奨
type Status = "active" | "inactive" | "pending";

// さらに型安全に
const STATUS = {
  ACTIVE: "active",
  INACTIVE: "inactive",
  PENDING: "pending",
} as const;

type Status = (typeof STATUS)[keyof typeof STATUS];
//            ↓
//            STATUS の”型”を取得
//                           ↓
//                           ”キー一覧”を取得: "ACTIVE" | "INACTIVE" | "PENDING"
//            ↓
//            それぞれのキーの”値の型”を取得
//            ↓
//            結果: "active" | "inactive" | "pending"

typeof演算子忘れた場合は#3を読み返してみましょう。

なぜこの方法(さらに型安全に)が良いのか

  1. Single Source of Truth: 値を1箇所で定義するだけで、型も自動生成される
  2. DRY原則: 同じ文字列を複数箇所に書く必要がない
  3. 型安全: 定数経由でも文字列リテラル直接でも、同じ型チェックが効く
  4. リファクタリングしやすい: 値を変更すれば、型も自動的に更新される

まとめ

TypeScript入門シリーズを通して、以下を学びました。

第1回:全体像

  • TypeScriptの基本と環境構築
  • 基本的な型の使い方

第2回:型の基礎

  • プリミティブ型、配列、オブジェクトの詳細
  • 型の応用パターン

第3回:関数とジェネリクス

  • 関数の型定義
  • ジェネリクスによる柔軟な型定義

第4回:クラスとインターフェース

  • オブジェクト指向プログラミング
  • クラス設計とデザインパターン

第5回:実践編(今回)

  • プロジェクト設定
  • 実践的なアプリケーション開発
  • ベストプラクティス

次のステップ

TypeScriptの基礎を習得したら、以下に挑戦してみましょう。

  1. フレームワークと組み合わせる
    • React + TypeScript
    • Vue + TypeScript
    • Next.js + TypeScript
    • Express + TypeScript
  2. 高度な型操作を学ぶ
    • Mapped Types
    • Conditional Types
    • Template Literal Types
    • Type Inference
  3. 実際のプロジェクトを作る
    • Todoアプリ
    • ブログシステム
    • ECサイト
    • チャットアプリ
  4. TypeScriptのエコシステムを探索
    • ESLint + TypeScript
    • Prettier
    • Jest
    • GraphQL + TypeScript

最後に

TypeScriptは、JavaScriptをより安全で保守しやすくする強力なツールです。

最初は型定義が面倒に感じるかもしれませんが、慣れてくると型システムの恩恵を強く感じるようになります。

重要なのは、完璧を目指さず、少しずつTypeScriptの機能を取り入れていくことです。

まずは基本的な型定義から始めて、徐々に高度な機能を使っていきましょう。

このシリーズが、あなたのTypeScript学習の助けになれば幸いです。 🚀

TypeScript入門シリーズ

第1回: 環境構築から全体像まで
第2回: 型の基礎を深く理解する
第3回: 関数と型、ジェネリクス入門
第4回: インターフェースとクラス
第5回: 実践編 – 実際のプロジェクトでの活用