はじめてのTypeScriptプログラミング入門 30日コース | Generics・Utility Types・Class・Module - Day 29:総合実践

TypeScript 30日で身につけるTypeScript
スポンサーリンク
スポンサーリンク

Day 29:総合実践 ― Todo管理アプリでTypeScriptを一気にまとめる

いよいよDay 29です。 ここまで学んできたTypeScriptの基本構文・Generics・Utility Types・Class・Moduleなどを、 ひとつの「Todo管理アプリ」にぎゅっと詰め込んで総復習していきます。

今回のゴールは、

「シンプルだけど、ちゃんとTypeScriptらしさが詰まっているTodo管理アプリ」を作ること

です。

Todo管理アプリの全体像をイメージする

Todo型の定義

まず、今回の主役となる Todo 型は次のように定義します。

type Todo = {
  id: number;
  title: string;
  completed: boolean;
  priority: "low" | "medium" | "high";
};
TypeScript

この型をベースに、次の必須機能を実装していきます。

  • Todo追加
  • Todo削除
  • Todo編集
  • 完了・未完了切り替え
  • 優先度変更
  • 検索
  • 絞り込み
  • 並び替え

そして、使うTypeScriptの要素は次のとおりです。

  • 基本型(number / string / boolean など)
  • 配列(Todo[])
  • Object(Todo)
  • Function(関数型)
  • Type Alias(type Todo = ...)
  • Union Type("low" | "medium" | "high")
  • Narrowing(条件分岐で型を絞る)
  • Generics(汎用関数)
  • Utility Types(Partial / Pick など)
  • Module(ファイル分割)

ここからは、モジュール構成を決めて、 少しずつ組み立てていきます。

モジュール構成を決める

今回は、次の4ファイル構成で進めます。

  • types.ts
    • Todo型や関連する型定義をまとめる
  • todoRepository.ts
    • Todoの追加・削除・編集などのロジックをまとめる
  • todoUtils.ts
    • 検索・絞り込み・並び替えなどの汎用関数をまとめる
  • main.ts
    • 実際にTodo管理アプリを動かす「エントリーポイント」

この構成は、実務でもよく使われる「役割分担」のパターンです。

Step 1:types.ts ― Todo型と関連型を定義する

まずは、types.ts に型定義をまとめます。

// types.ts
// Todo管理アプリで使う型定義をまとめるファイル

// 優先度の型(Union Type)
export type Priority = "low" | "medium" | "high";

// Todoの型(Type Alias)
export type Todo = {
  id: number;          // 一意なID
  title: string;       // タイトル
  completed: boolean;  // 完了フラグ
  priority: Priority;  // 優先度
};

// Todoの更新用の型(Utility Typesを活用)
export type TodoUpdate = Partial<Pick<Todo, "title" | "completed" | "priority">>;
/*
  TodoUpdateは、
  - title
  - completed
  - priority
  のいずれか(または複数)を「任意で」更新できる型です。
  つまり、編集機能で「一部だけ変更したい」ときに使えます。
*/
TypeScript

ここでの重要ポイント:

  • Priority は Union Type で、「low / medium / high」の3つに絞っています。
  • TodoUpdate は Partial と Pick を組み合わせた Utility Types の活用例です。
    • Pick<Todo, "title" | "completed" | "priority"> で、Todoから一部のプロパティだけを抜き出し、
    • Partial<...> で「全部任意(オプション)」にしています。
    • これにより、「タイトルだけ変える」「優先度だけ変える」といった更新が自然に書けます。

Step 2:todoRepository.ts ― Todoの基本操作をまとめる

次に、Todoの追加・削除・編集などの基本操作を todoRepository.ts にまとめます。

// todoRepository.ts
// Todoの追加・削除・編集などを担当するモジュール

import { Todo, Priority, TodoUpdate } from "./types";

// 内部で管理するTodo一覧(配列)
let todos: Todo[] = [];

// 次のIDを生成するためのカウンタ
let nextId = 1;

// Todoを追加する関数
export function addTodo(title: string, priority: Priority = "medium"): Todo {
  const newTodo: Todo = {
    id: nextId++,
    title,
    completed: false,
    priority,
  };

  todos.push(newTodo);
  return newTodo;
}

// Todoを削除する関数
export function removeTodo(id: number): boolean {
  const beforeLength = todos.length;
  todos = todos.filter((todo) => todo.id !== id);
  const afterLength = todos.length;

  // 削除できたかどうかをbooleanで返す
  return afterLength < beforeLength;
}

// Todoを編集する関数(Utility Typesを活用)
export function updateTodo(id: number, update: TodoUpdate): Todo | null {
  const index = todos.findIndex((todo) => todo.id === id);
  if (index === -1) {
    return null; // 見つからなかった
  }

  const oldTodo = todos[index];

  // スプレッド構文で部分更新
  const newTodo: Todo = {
    ...oldTodo,
    ...update,
  };

  todos[index] = newTodo;
  return newTodo;
}

// 完了・未完了を切り替える関数
export function toggleTodoCompleted(id: number): Todo | null {
  const todo = todos.find((t) => t.id === id);
  if (!todo) {
    return null;
  }

  todo.completed = !todo.completed;
  return todo;
}

// 優先度を変更する関数
export function changeTodoPriority(id: number, priority: Priority): Todo | null {
  const todo = todos.find((t) => t.id === id);
  if (!todo) {
    return null;
  }

  todo.priority = priority;
  return todo;
}

// Todo一覧を取得する関数
export function getTodos(): Todo[] {
  // 外から直接いじられないようにコピーを返す
  return [...todos];
}
TypeScript

ここでの重要ポイント:

  • todos と nextId は export していないので、 モジュール内部の「隠れた状態」として扱われます。
  • updateTodo で TodoUpdate を使うことで、 「一部だけ更新する」という操作が型安全に書けています。
  • toggleTodoCompleted や changeTodoPriority は、 「完了・未完了」「優先度変更」の必須機能を担当しています。

Step 3:todoUtils.ts ― 検索・絞り込み・並び替えを汎用関数で書く

次に、検索・絞り込み・並び替えを todoUtils.ts にまとめます。 ここでは、Genericsも少し使ってみます。

// todoUtils.ts
// 検索・絞り込み・並び替えなどの汎用関数をまとめるモジュール

import { Todo, Priority } from "./types";

// タイトルにキーワードを含むTodoを検索する関数
export function searchTodos(todos: Todo[], keyword: string): Todo[] {
  const lower = keyword.toLowerCase();
  return todos.filter((todo) => todo.title.toLowerCase().includes(lower));
}

// 完了・未完了で絞り込む関数
export function filterByCompleted(todos: Todo[], completed: boolean): Todo[] {
  return todos.filter((todo) => todo.completed === completed);
}

// 優先度で絞り込む関数
export function filterByPriority(todos: Todo[], priority: Priority): Todo[] {
  return todos.filter((todo) => todo.priority === priority);
}

// 並び替え:優先度順(high → medium → low)
export function sortByPriority(todos: Todo[]): Todo[] {
  const order: Priority[] = ["high", "medium", "low"];

  return [...todos].sort((a, b) => {
    const aIndex = order.indexOf(a.priority);
    const bIndex = order.indexOf(b.priority);
    return aIndex - bIndex;
  });
}

// 並び替え:タイトルのアルファベット順
export function sortByTitle(todos: Todo[]): Todo[] {
  return [...todos].sort((a, b) => a.title.localeCompare(b.title));
}

// 汎用ソート関数(Genericsを活用)
export function sortByKey<T, K extends keyof T>(
  items: T[],
  key: K
): T[] {
  return [...items].sort((a, b) => {
    const aValue = a[key];
    const bValue = b[key];

    // Narrowing:stringの場合とnumberの場合で処理を分ける
    if (typeof aValue === "string" && typeof bValue === "string") {
      return aValue.localeCompare(bValue);
    }

    if (typeof aValue === "number" && typeof bValue === "number") {
      return aValue - bValue;
    }

    // それ以外の型はそのまま(順序なし)
    return 0;
  });
}
TypeScript

ここでの重要ポイント:

  • searchTodos / filterByCompleted / filterByPriority / sortByPriority / sortByTitle が、 Todo専用の検索・絞り込み・並び替え関数です。
  • sortByKey<T, K extends keyof T> は、 Generics + keyof + Narrowing を組み合わせた汎用ソート関数です。
    • K extends keyof T によって、「Tのプロパティ名だけ」をキーとして受け取ります。
    • typeof aValue === "string" などの条件分岐で、 型に応じて処理を変える「Narrowing」を行っています。

Step 4:main.ts ― Todo管理アプリを実際に動かす

最後に、main.ts で全体を組み立てて、 Todo管理アプリをコンソール上で動かしてみます。

// main.ts
// Todo管理アプリのエントリーポイント

import { Priority } from "./types";
import {
  addTodo,
  removeTodo,
  updateTodo,
  toggleTodoCompleted,
  changeTodoPriority,
  getTodos,
} from "./todoRepository";
import {
  searchTodos,
  filterByCompleted,
  filterByPriority,
  sortByPriority,
  sortByTitle,
  sortByKey,
} from "./todoUtils";

// Todo一覧を表示する関数
function printTodos(label: string, todos = getTodos()): void {
  console.log(`\n=== ${label} ===`);
  if (todos.length === 0) {
    console.log("Todoはまだありません");
    return;
  }

  todos.forEach((todo) => {
    const status = todo.completed ? "✅ 完了" : "⏳ 未完了";
    console.log(
      `ID: ${todo.id} / ${status} / [${todo.priority}] ${todo.title}`
    );
  });
}

// 実行例
function main(): void {
  console.log("=== Todo管理アプリ(総合実践) ===");

  // Todo追加
  const t1 = addTodo("TypeScriptの勉強をする", "high");
  const t2 = addTodo("買い物に行く", "medium");
  const t3 = addTodo("部屋を片付ける", "low");
  const t4 = addTodo("ランニングをする", "medium");

  printTodos("初期状態");

  // 完了・未完了切り替え
  toggleTodoCompleted(t2.id); // 買い物を完了にする
  toggleTodoCompleted(t4.id); // ランニングを完了にする

  printTodos("完了・未完了を更新後");

  // 優先度変更
  changeTodoPriority(t3.id, "high"); // 部屋片付けを高優先度に

  printTodos("優先度変更後");

  // Todo編集(タイトル変更)
  updateTodo(t1.id, { title: "TypeScriptのGenericsを復習する" });

  printTodos("タイトル編集後");

  // 検索
  const searchResult = searchTodos(getTodos(), "する");
  printTodos("「する」を含むTodoの検索結果", searchResult);

  // 絞り込み:完了のみ
  const completedTodos = filterByCompleted(getTodos(), true);
  printTodos("完了したTodoのみ", completedTodos);

  // 絞り込み:優先度highのみ
  const highPriorityTodos = filterByPriority(getTodos(), "high");
  printTodos("優先度highのみ", highPriorityTodos);

  // 並び替え:優先度順
  const sortedByPriority = sortByPriority(getTodos());
  printTodos("優先度順に並び替え", sortedByPriority);

  // 並び替え:タイトル順
  const sortedByTitle = sortByTitle(getTodos());
  printTodos("タイトル順に並び替え", sortedByTitle);

  // 汎用ソート関数でID順に並び替え
  const sortedById = sortByKey(getTodos(), "id");
  printTodos("汎用ソート関数でID順に並び替え", sortedById);

  // Todo削除
  removeTodo(t2.id); // 買い物のTodoを削除

  printTodos("削除後の一覧");
}

// エントリーポイントを呼び出す
main();
TypeScript

ここでの重要ポイント:

  • printTodos 関数で、Todo一覧の表示ロジックをひとまとめにしています。
  • main 関数の中で、
    • 追加
    • 完了・未完了切り替え
    • 優先度変更
    • 編集
    • 検索
    • 絞り込み
    • 並び替え
    • 削除 を順番に実行しながら、状態の変化をコンソールに出しています。
  • sortByKey(getTodos(), "id") のように、 Genericsを使った汎用ソート関数を実際に使っているのもポイントです。

Day 29用テンプレート一式(まとめて確認用)

最後に、4ファイル分をまとめて確認できるように、 もう一度コードを一覧しておきます。

types.ts

// types.ts
export type Priority = "low" | "medium" | "high";

export type Todo = {
  id: number;
  title: string;
  completed: boolean;
  priority: Priority;
};

export type TodoUpdate = Partial<Pick<Todo, "title" | "completed" | "priority">>;
TypeScript

todoRepository.ts

// todoRepository.ts
import { Todo, Priority, TodoUpdate } from "./types";

let todos: Todo[] = [];
let nextId = 1;

export function addTodo(title: string, priority: Priority = "medium"): Todo {
  const newTodo: Todo = {
    id: nextId++,
    title,
    completed: false,
    priority,
  };
  todos.push(newTodo);
  return newTodo;
}

export function removeTodo(id: number): boolean {
  const beforeLength = todos.length;
  todos = todos.filter((todo) => todo.id !== id);
  const afterLength = todos.length;
  return afterLength < beforeLength;
}

export function updateTodo(id: number, update: TodoUpdate): Todo | null {
  const index = todos.findIndex((todo) => todo.id === id);
  if (index === -1) {
    return null;
  }
  const oldTodo = todos[index];
  const newTodo: Todo = {
    ...oldTodo,
    ...update,
  };
  todos[index] = newTodo;
  return newTodo;
}

export function toggleTodoCompleted(id: number): Todo | null {
  const todo = todos.find((t) => t.id === id);
  if (!todo) {
    return null;
  }
  todo.completed = !todo.completed;
  return todo;
}

export function changeTodoPriority(id: number, priority: Priority): Todo | null {
  const todo = todos.find((t) => t.id === id);
  if (!todo) {
    return null;
  }
  todo.priority = priority;
  return todo;
}

export function getTodos(): Todo[] {
  return [...todos];
}
TypeScript

todoUtils.ts

// todoUtils.ts
import { Todo, Priority } from "./types";

export function searchTodos(todos: Todo[], keyword: string): Todo[] {
  const lower = keyword.toLowerCase();
  return todos.filter((todo) => todo.title.toLowerCase().includes(lower));
}

export function filterByCompleted(todos: Todo[], completed: boolean): Todo[] {
  return todos.filter((todo) => todo.completed === completed);
}

export function filterByPriority(todos: Todo[], priority: Priority): Todo[] {
  return todos.filter((todo) => todo.priority === priority);
}

export function sortByPriority(todos: Todo[]): Todo[] {
  const order: Priority[] = ["high", "medium", "low"];
  return [...todos].sort((a, b) => {
    const aIndex = order.indexOf(a.priority);
    const bIndex = order.indexOf(b.priority);
    return aIndex - bIndex;
  });
}

export function sortByTitle(todos: Todo[]): Todo[] {
  return [...todos].sort((a, b) => a.title.localeCompare(b.title));
}

export function sortByKey<T, K extends keyof T>(items: T[], key: K): T[] {
  return [...items].sort((a, b) => {
    const aValue = a[key];
    const bValue = b[key];

    if (typeof aValue === "string" && typeof bValue === "string") {
      return aValue.localeCompare(bValue);
    }

    if (typeof aValue === "number" && typeof bValue === "number") {
      return aValue - bValue;
    }

    return 0;
  });
}
TypeScript

main.ts

// main.ts
import { Priority } from "./types";
import {
  addTodo,
  removeTodo,
  updateTodo,
  toggleTodoCompleted,
  changeTodoPriority,
  getTodos,
} from "./todoRepository";
import {
  searchTodos,
  filterByCompleted,
  filterByPriority,
  sortByPriority,
  sortByTitle,
  sortByKey,
} from "./todoUtils";

function printTodos(label: string, todos = getTodos()): void {
  console.log(`\n=== ${label} ===`);
  if (todos.length === 0) {
    console.log("Todoはまだありません");
    return;
  }

  todos.forEach((todo) => {
    const status = todo.completed ? "✅ 完了" : "⏳ 未完了";
    console.log(
      `ID: ${todo.id} / ${status} / [${todo.priority}] ${todo.title}`
    );
  });
}

function main(): void {
  console.log("=== Todo管理アプリ(総合実践) ===");

  const t1 = addTodo("TypeScriptの勉強をする", "high");
  const t2 = addTodo("買い物に行く", "medium");
  const t3 = addTodo("部屋を片付ける", "low");
  const t4 = addTodo("ランニングをする", "medium");

  printTodos("初期状態");

  toggleTodoCompleted(t2.id);
  toggleTodoCompleted(t4.id);
  printTodos("完了・未完了を更新後");

  changeTodoPriority(t3.id, "high");
  printTodos("優先度変更後");

  updateTodo(t1.id, { title: "TypeScriptのGenericsを復習する" });
  printTodos("タイトル編集後");

  const searchResult = searchTodos(getTodos(), "する");
  printTodos("「する」を含むTodoの検索結果", searchResult);

  const completedTodos = filterByCompleted(getTodos(), true);
  printTodos("完了したTodoのみ", completedTodos);

  const highPriorityTodos = filterByPriority(getTodos(), "high");
  printTodos("優先度highのみ", highPriorityTodos);

  const sortedByPriority = sortByPriority(getTodos());
  printTodos("優先度順に並び替え", sortedByPriority);

  const sortedByTitle = sortByTitle(getTodos());
  printTodos("タイトル順に並び替え", sortedByTitle);

  const sortedById = sortByKey(getTodos(), "id");
  printTodos("汎用ソート関数でID順に並び替え", sortedById);

  removeTodo(t2.id);
  printTodos("削除後の一覧");
}

main();
TypeScript

Day 29のまとめ

Day 29では、

  • Todo型を中心にしたアプリ設計
  • Type Alias / Union Type / Utility Types を使った型設計
  • 配列・Object・Functionを組み合わせたロジック構成
  • GenericsとNarrowingを使った汎用ソート関数
  • Moduleによるファイル分割と役割分担

を、ひとつの「Todo管理アプリ」にまとめて体験しました。

ここまで来ると、 TypeScriptで「小さなアプリ」を自分の手で組み立てる感覚が、 かなりはっきりしてきているはずです。

このテンプレートをベースに、

  • 期限(dueDate)を追加して、期限順の並び替えを実装する
  • completed と priority を組み合わせた複合絞り込みを作る
  • Moduleを増やして、UI層や永続化層(ローカルストレージなど)を分けてみる

など、少しずつ発展させていくことで、 TypeScriptの世界がさらに立体的に見えてくると思います。

タイトルとURLをコピーしました