404 Not Found

404 Not Found


nginx

TypeScript 総合プロジェクト

最初の29レッスンを終えたところで、いよいよ学んだ知識をすべて統合する時が来ました。このレッスンでは、TypeScriptを使ってCLI版のToDoアプリを一から構築し、型設計、ジェネリック、モジュール化、エラー処理といった中核となる概念について学びます。

1. プロジェクトの概要

(1) 機能要件

TEXT
Todo CLI Tools
├── Add a Task(add)
├── List Tasks(list)
├── Complete the task(done)
├── Delete Task(delete)
├── Filter by Status(list --done / list --pending)
└── Data Persistence(JSON File Storage)

(2) 技術要件


2. タイプデザイン

(1) コア型の定義

TYPESCRIPT
// types.ts
type Priority = "low" | "medium" | "high";
type TodoStatus = "pending" | "done";

interface Todo {
  id: string;
  title: string;
  description?: string;
  priority: Priority;
  status: TodoStatus;
  createdAt: string;
  updatedAt: string;
}

// Derivation Using Tool Types——DRY Principles
type CreateTodo = Omit<Todo, "id" | "createdAt" | "updatedAt">;
type UpdateTodo = Partial<Omit<Todo, "id" | "createdAt">>;

interface TodoFilter {
  status?: TodoStatus;
  priority?: Priority;
}

type SortBy = "createdAt" | "priority" | "title";
type SortOrder = "asc" | "desc";

interface TodoSort {
  by: SortBy;
  order: SortOrder;
}

(2) 結果のパターン

TYPESCRIPT
// result.ts
type Success<T> = { ok: true; value: T };
type Failure<E> = { ok: false; error: E };
type Result<T, E = string> = Success<T> | Failure<E>;

function ok<T>(value: T): Success<T> {
  return { ok: true, value };
}

function err<E>(error: E): Failure<E> {
  return { ok: false, error };
}

3. 汎用ストレージ層

(1) ストレージインターフェース

TYPESCRIPT
// storage.ts
interface Storage<T extends { id: string }> {
  getAll(): Result<T[]>;
  getById(id: string): Result<T | null>;
  save(item: T): Result<T>;
  delete(id: string): Result<boolean>;
  find(predicate: (item: T) => boolean): Result<T[]>;
}

(2) JSONファイルの保存機能の実装

TYPESCRIPT
class JsonFileStorage<T extends { id: string }> implements Storage<T> {
  private data: Map<string, T> = new Map();
  private loaded: boolean = false;

  constructor(private filePath: string) {}

  private load(): Result<void> {
    if (this.loaded) return ok(undefined);
    try {
      let fs = require("fs");
      if (fs.existsSync(this.filePath)) {
        let raw = fs.readFileSync(this.filePath, "utf-8");
        let items: T[] = JSON.parse(raw);
        items.forEach(item => this.data.set(item.id, item));
      }
      this.loaded = true;
      return ok(undefined);
    } catch (error) {
      return err(`Failed to load:${error instanceof Error ? error.message : String(error)}`);
    }
  }

  private persist(): Result<void> {
    try {
      let fs = require("fs");
      let items = Array.from(this.data.values());
      fs.writeFileSync(this.filePath, JSON.stringify(items, null, 2), "utf-8");
      return ok(undefined);
    } catch (error) {
      return err(`Save Failed:${error instanceof Error ? error.message : String(error)}`);
    }
  }

  getAll(): Result<T[]> {
    let r = this.load();
    if (!r.ok) return err(r.error);
    return ok(Array.from(this.data.values()));
  }

  getById(id: string): Result<T | null> {
    let r = this.load();
    if (!r.ok) return err(r.error);
    return ok(this.data.get(id) ?? null);
  }

  save(item: T): Result<T> {
    let r = this.load();
    if (!r.ok) return err(r.error);
    this.data.set(item.id, { ...item, updatedAt: new Date().toISOString() } as T);
    let p = this.persist();
    if (!p.ok) return err(p.error);
    return ok(item);
  }

  delete(id: string): Result<boolean> {
    let r = this.load();
    if (!r.ok) return err(r.error);
    let existed = this.data.delete(id);
    let p = this.persist();
    if (!p.ok) return err(p.error);
    return ok(existed);
  }

  find(predicate: (item: T) => boolean): Result<T[]> {
    let r = this.getAll();
    if (!r.ok) return err(r.error);
    return ok(r.value.filter(predicate));
  }
}

4. ビジネスロジック層

TYPESCRIPT
// service.ts
class TodoService {
  private idCounter: number = 0;

  constructor(private storage: Storage<Todo>) {}

  private generateId(): string {
    return `todo_${Date.now()}_${++this.idCounter}`;
  }

  add(input: CreateTodo): Result<Todo> {
    let now = new Date().toISOString();
    let todo: Todo = {
      id: this.generateId(),
      title: input.title,
      description: input.description,
      priority: input.priority,
      status: "pending",
      createdAt: now,
      updatedAt: now
    };
    return this.storage.save(todo);
  }

  list(filter?: TodoFilter, sort?: TodoSort): Result<Todo[]> {
    let r = this.storage.getAll();
    if (!r.ok) return err(r.error);

    let todos = r.value;

    // Filter
    if (filter?.status) todos = todos.filter(t => t.status === filter.status);
    if (filter?.priority) todos = todos.filter(t => t.priority === filter.priority);

    // Sort
    let sortBy: SortBy = sort?.by ?? "createdAt";
    let order: SortOrder = sort?.order ?? "desc";

    let priorityOrder: Record<Priority, number> = { high: 3, medium: 2, low: 1 };

    todos.sort((a, b) => {
      let cmp = 0;
      if (sortBy === "createdAt") cmp = a.createdAt.localeCompare(b.createdAt);
      else if (sortBy === "priority") cmp = priorityOrder[a.priority] - priorityOrder[b.priority];
      else if (sortBy === "title") cmp = a.title.localeCompare(b.title);
      return order === "asc" ? cmp : -cmp;
    });

    return ok(todos);
  }

  done(id: string): Result<Todo> {
    let r = this.storage.getById(id);
    if (!r.ok) return err(r.error);
    if (!r.value) return err(`Task ${id} Does not exist`);
    if (r.value.status === "done") return err(`Task ${id} Completed`);
    return this.storage.save({ ...r.value, status: "done" });
  }

  delete(id: string): Result<boolean> {
    return this.storage.delete(id);
  }

  update(id: string, updates: UpdateTodo): Result<Todo> {
    let r = this.storage.getById(id);
    if (!r.ok) return err(r.error);
    if (!r.value) return err(`Task ${id} Does not exist`);
    return this.storage.save({ ...r.value, ...updates });
  }
}

▶ 例:サービス層のテスト

TYPESCRIPT
// Usage——Create a service and perform an operation
let storage = new JsonFileStorage<Todo>("./todos.json");
let service = new TodoService(storage);

// Add a Task
let addResult = service.add({ title: "StudyTypeScript", priority: "high" });
if (addResult.ok) {
  console.log(`Added:${addResult.value.title}`);
}

// List Tasks
let listResult = service.list({ status: "pending" });
if (listResult.ok) {
  console.log(`To-Do List:${listResult.value.length} items`);
  listResult.value.forEach(t => console.log(`  - ${t.title} [${t.priority}]`));
}

// Complete the task
if (addResult.ok) {
  let doneResult = service.done(addResult.value.id);
  if (doneResult.ok) {
    console.log(`Completed:${doneResult.value.title}`);
  }
}

5. CLI プレゼンテーション層

TYPESCRIPT
// cli.ts
class TodoCLI {
  constructor(private service: TodoService) {}

  run(command: string, args: string[]): void {
    switch (command) {
      case "add": this.handleAdd(args); break;
      case "list": this.handleList(args); break;
      case "done": this.handleDone(args); break;
      case "delete": this.handleDelete(args); break;
      default: this.printHelp();
    }
  }

  private handleAdd(args: string[]): void {
    if (args.length === 0) {
      console.log("Usage:add <Title> [Priority:low|medium|high]");
      return;
    }
    let title = args[0];
    let priority: Priority =
      args[1] === "low" || args[1] === "medium" || args[1] === "high"
        ? args[1] : "medium";

    let r = this.service.add({ title, priority });
    if (r.ok) {
      console.log(`✅ Added:${r.value.title} [${r.value.priority}]`);
    } else {
      console.log(`❌ Failed to add:${r.error}`);
    }
  }

  private handleList(args: string[]): void {
    let filter: TodoFilter = {};
    if (args.includes("--done")) filter.status = "done";
    if (args.includes("--pending")) filter.status = "pending";

    let r = this.service.list(filter);
    if (!r.ok) { console.log(`❌ Query Failed:${r.error}`); return; }
    if (r.value.length === 0) { console.log("📭 No tasks"); return; }

    console.log("\n📋 Task List:");
    console.log("─".repeat(50));
    for (let t of r.value) {
      let s = t.status === "done" ? "✅" : "⬜";
      let p = { low: "🟢", medium: "🟡", high: "🔴" }[t.priority];
      console.log(`${s} ${p} [${t.id}] ${t.title}`);
    }
    console.log("─".repeat(50));
    console.log(`Total ${r.value.length} task`);
  }

  private handleDone(args: string[]): void {
    if (args.length === 0) { console.log("Usage:done <TaskID>"); return; }
    let r = this.service.done(args[0]);
    if (r.ok) console.log(`✅ Completed:${r.value.title}`);
    else console.log(`❌ Operation Failed:${r.error}`);
  }

  private handleDelete(args: string[]): void {
    if (args.length === 0) { console.log("Usage:delete <TaskID>"); return; }
    let r = this.service.delete(args[0]);
    if (r.ok && r.value) console.log("🗑️ Deleted");
    else if (r.ok) console.log("❌ The task does not exist.");
    else console.log(`❌ Deletion Failed:${r.error}`);
  }

  private printHelp(): void {
    console.log("Todo CLI Tools:");
    console.log("  add <Title> [Priority]       Add a Task");
    console.log("  list [--done|--pending]   List Tasks");
    console.log("  done <ID>                 Complete the task");
    console.log("  delete <ID>               Delete Task");
  }
}

6. 入力ファイル

TYPESCRIPT
// index.ts
import { TodoService } from "./service";
import { JsonFileStorage } from "./storage";
import { TodoCLI } from "./cli";
import type { Todo } from "./types";

let storage = new JsonFileStorage<Todo>("./todos.json");
let service = new TodoService(storage);
let cli = new TodoCLI(service);

let args = process.argv.slice(2);
let command = args[0] ?? "list";
let commandArgs = args.slice(1);

cli.run(command, commandArgs);

7. プロジェクト知識リスト

このプロジェクトで取り上げる概念は、以下の過去の授業の内容に対応しています:

主要概念 関連コース プロジェクトへの応用
プリミティブ型 第4課 Todoの文字列型・ブール型
型推論 第5課 省略された変数に対する型注釈
ユニオン型 第6課 優先度、TodoStatus
配列 第7課 Todo[] への格納
オブジェクトの種類 第8課 TodoおよびTodoFilterインターフェース
インターフェース 第9課 ストレージ・インターフェースの定義
関数の型 第10課 述語のコールバック引数
型の別名 第11課 戻り値の型
クラス 第13課 TodoService、JsonFileStorage
クラスとインターフェース 第14課 Storage を実装
ジェネリック 第15課 ストレージへのジェネリックパラメータの追加
ジェネリック制約 第16課 T extends { id: string }
型ガード 第17課 result.ok の型絞り
型アサーション 第18課 as T アサーション
ツールの種類 レッスン19 省略、部分、記録
モジュール 第22課 モジュールのインポート/エクスポート
tsconfig 第24課 プロジェクトの設定
エラー処理 第26課 結果のパターン

❓ よくある質問

Q このプロジェクトにはどのような機能を追加できますか?
A 拡張の提案:(1)非同期ストレージ—async/await への切り替え (2)タグシステム—タスクに string[] タグを追加 (3)サブタスク—タスクに子タスクを設定可能 (4)CSV/Markdown へのエクスポート (5)複数のリストのサポート。それぞれの拡張機能は、これまでのレッスンで学んだ内容を定着させるための素晴らしい練習になります。
Q なぜ try/catch ではなく Result パターンを使うのですか?
A CLI ツールでは、エラーは予測可能なビジネス上のエラー(タスクが存在しないなど)です。Result パターンを使えば、関数のシグネチャにおいて、その操作が「失敗する可能性がある」ことが明確になり、呼び出し側がそれを処理しなければならないことがわかります。このシナリオでは、try/catch よりも Result パターンの方が適しています。つまり、エラーは「例外」ではなく「通常の分岐」だからです。
Q なぜストレージ層では、Todoを直接使用するのではなく、ジェネリックを使用しているのですか?
A ジェネリックを使用することで、ストレージ層の再利用が可能になります。ジェネリックパラメータを追加することで、JsonFileStorageidプロパティを持つあらゆるエンティティを格納できるようになります。プロジェクトが拡大しても、エンティティごとに新しいストレージの実装を記述する必要がなくなります。これがジェネリックの核心的な価値、つまり「一度書いて、どこでも使える」ということです。
Q プロジェクトをWeb APIに変換するにはどうすればよいですか?
A コアアーキテクチャは変更されません。CLIレイヤーをExpressのルーティングレイヤーに置き換えるだけで済みます。ストレージ層とサービス層は完全に再利用されます。これが階層型アーキテクチャの利点であり、ビジネスロジックに影響を与えることなくプレゼンテーション層を置き換えることができるのです。

📖 まとめ

📝 練習問題

  1. 基本演習(難易度:⭐):プロジェクトのコードをモジュールごとに個別の .ts ファイルに分割し、tsconfig.json を設定して、tsc が正常にコンパイルされることを確認してください。
  2. 上級者向け演習(難易度 ⭐⭐):TodoService に tag 機能を追加します。また、タグ list --tag <labelName> によるフィルタリングに対応するため、Todo に tags: string[] プロパティを追加します。これには、型定義、ストレージ層、および CLI 層の修正が必要になります。
  3. 課題(難易度:⭐⭐⭐):同期型ストレージを非同期型ストレージに変換する――Storage<T>内のすべてのメソッドをasyncに変更し、Promise<Result<T>>を返すようにする。また、Service層とCLI層をasync/awaitに変換する。非同期エラーを処理し、型安全性を確保する。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%