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) 技術要件
- 純粋なTypeScriptで、サードパーティの依存関係はありません
- モジュール型設計—型、ストレージ、ビジネスロジック、CLIの階層化
- 完全な型安全性—
anyなし - 結果モードでのエラー処理
- 再利用可能な汎用ストレージ層
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 ジェネリックを使用することで、ストレージ層の再利用が可能になります。ジェネリックパラメータを追加することで、
JsonFileStorageはidプロパティを持つあらゆるエンティティを格納できるようになります。プロジェクトが拡大しても、エンティティごとに新しいストレージの実装を記述する必要がなくなります。これがジェネリックの核心的な価値、つまり「一度書いて、どこでも使える」ということです。Q プロジェクトをWeb APIに変換するにはどうすればよいですか?
A コアアーキテクチャは変更されません。CLIレイヤーをExpressのルーティングレイヤーに置き換えるだけで済みます。ストレージ層とサービス層は完全に再利用されます。これが階層型アーキテクチャの利点であり、ビジネスロジックに影響を与えることなくプレゼンテーション層を置き換えることができるのです。
📖 まとめ
- タイプデザインから始め、
Todo、CreateTodo、UpdateTodo、Resultといった主要なタイプを定義する - DRYの原則に従い、ツールタイプ(Omit、Partial)を使用してTodoからバリアントを導出する
- 汎用ストレージ層
Storage<T extends { id: string }>は、どのエンティティに対しても再利用可能です - Result パターンでは、エラー処理が明示的に行われます。関数のシグネチャで「失敗する可能性がある」ことが宣言されており、呼び出し側はエラーを処理しなければなりません。
- 階層型アーキテクチャ:タイプ層 → ストレージ層 → サービス層 → プレゼンテーション層――各層の役割は明確に定義されており、他の層とは独立している
- 第29課で取り上げられた概念のほとんど――基本型からジェネリック、エラー処理に至るまで――を統合しています
📝 練習問題
- 基本演習(難易度:⭐):プロジェクトのコードをモジュールごとに個別の .ts ファイルに分割し、tsconfig.json を設定して、
tscが正常にコンパイルされることを確認してください。 - 上級者向け演習(難易度 ⭐⭐):TodoService に
tag機能を追加します。また、タグlist --tag <labelName>によるフィルタリングに対応するため、Todo にtags: string[]プロパティを追加します。これには、型定義、ストレージ層、および CLI 層の修正が必要になります。 - 課題(難易度:⭐⭐⭐):同期型ストレージを非同期型ストレージに変換する――
Storage<T>内のすべてのメソッドをasyncに変更し、Promise<Result<T>>を返すようにする。また、Service層とCLI層をasync/awaitに変換する。非同期エラーを処理し、型安全性を確保する。



