第9章: 実践プロジェクト¶
この章で学ぶこと¶
- 関数型プログラミングの実践的な適用
- TODOアプリケーションの関数型実装
- 状態管理の関数型アプローチ
- テスタブルなアーキテクチャの構築
プロジェクト概要¶
関数型プログラミングの概念を活用して、TODOアプリケーションを実装します。
要件¶
- TODOの追加・削除・更新
- TODOの完了状態の切り替え
- フィルタリング(全て、アクティブ、完了済み)
- ローカルストレージへの永続化
- 非同期でのデータ同期(シミュレーション)
ドメインモデル¶
// TODOアイテムの型定義
type TodoId = string;
type Todo = {
id: TodoId;
title: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
};
// フィルターの種類
type FilterType = "all" | "active" | "completed";
// アプリケーション状態
type AppState = {
todos: Todo[];
filter: FilterType;
isLoading: boolean;
error: string | null;
};
純粋関数によるビジネスロジック¶
TODOの操作関数¶
// TODOの作成
const createTodo = (title: string): Todo => ({
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
updatedAt: new Date()
});
// TODOリストへの追加
const addTodo = (todos: Todo[], title: string): Todo[] => {
if (title.trim() === "") return todos;
return [...todos, createTodo(title)];
};
// TODOの更新
const updateTodo = (
todos: Todo[],
id: TodoId,
updates: Partial<Omit<Todo, "id" | "createdAt">>
): Todo[] =>
todos.map(todo =>
todo.id === id
? { ...todo, ...updates, updatedAt: new Date() }
: todo
);
// TODOの削除
const removeTodo = (todos: Todo[], id: TodoId): Todo[] =>
todos.filter(todo => todo.id !== id);
// 完了状態の切り替え
const toggleTodo = (todos: Todo[], id: TodoId): Todo[] =>
updateTodo(todos, id, {
completed: !todos.find(t => t.id === id)?.completed
});
// すべて完了/未完了にする
const toggleAll = (todos: Todo[], completed: boolean): Todo[] =>
todos.map(todo => ({
...todo,
completed,
updatedAt: new Date()
}));
// 完了済みをクリア
const clearCompleted = (todos: Todo[]): Todo[] =>
todos.filter(todo => !todo.completed);
フィルタリング関数¶
// フィルタリングロジック
const filterTodos = (todos: Todo[], filter: FilterType): Todo[] => {
switch (filter) {
case "active":
return todos.filter(todo => !todo.completed);
case "completed":
return todos.filter(todo => todo.completed);
case "all":
default:
return todos;
}
};
// 統計情報の計算
type TodoStats = {
total: number;
active: number;
completed: number;
};
const calculateStats = (todos: Todo[]): TodoStats => ({
total: todos.length,
active: todos.filter(todo => !todo.completed).length,
completed: todos.filter(todo => todo.completed).length
});
状態管理¶
Reduxスタイルのアクション¶
// アクションタイプ
type Action =
| { type: "ADD_TODO"; payload: string }
| { type: "REMOVE_TODO"; payload: TodoId }
| { type: "TOGGLE_TODO"; payload: TodoId }
| { type: "UPDATE_TODO"; payload: { id: TodoId; title: string } }
| { type: "TOGGLE_ALL"; payload: boolean }
| { type: "CLEAR_COMPLETED" }
| { type: "SET_FILTER"; payload: FilterType }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_TODOS"; payload: Todo[] };
// リデューサー(純粋関数)
const reducer = (state: AppState, action: Action): AppState => {
switch (action.type) {
case "ADD_TODO":
return {
...state,
todos: addTodo(state.todos, action.payload)
};
case "REMOVE_TODO":
return {
...state,
todos: removeTodo(state.todos, action.payload)
};
case "TOGGLE_TODO":
return {
...state,
todos: toggleTodo(state.todos, action.payload)
};
case "UPDATE_TODO":
return {
...state,
todos: updateTodo(
state.todos,
action.payload.id,
{ title: action.payload.title }
)
};
case "TOGGLE_ALL":
return {
...state,
todos: toggleAll(state.todos, action.payload)
};
case "CLEAR_COMPLETED":
return {
...state,
todos: clearCompleted(state.todos)
};
case "SET_FILTER":
return {
...state,
filter: action.payload
};
case "SET_LOADING":
return {
...state,
isLoading: action.payload
};
case "SET_ERROR":
return {
...state,
error: action.payload
};
case "SET_TODOS":
return {
...state,
todos: action.payload
};
default:
return state;
}
};
状態管理のストア実装¶
// シンプルなストア実装
type Listener<T> = (state: T) => void;
type Unsubscribe = () => void;
class Store<S, A> {
private state: S;
private listeners: Set<Listener<S>> = new Set();
constructor(
private reducer: (state: S, action: A) => S,
initialState: S
) {
this.state = initialState;
}
getState(): S {
return this.state;
}
dispatch(action: A): void {
this.state = this.reducer(this.state, action);
this.listeners.forEach(listener => listener(this.state));
}
subscribe(listener: Listener<S>): Unsubscribe {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
// ストアの作成
const initialState: AppState = {
todos: [],
filter: "all",
isLoading: false,
error: null
};
const store = new Store(reducer, initialState);
永続化レイヤー¶
ローカルストレージの操作¶
// ストレージのキー
const STORAGE_KEY = "todos-fp";
// シリアライズ/デシリアライズ
const serializeTodo = (todo: Todo): any => ({
...todo,
createdAt: todo.createdAt.toISOString(),
updatedAt: todo.updatedAt.toISOString()
});
const deserializeTodo = (data: any): Result<Todo, string> => {
try {
return ok({
id: data.id,
title: data.title,
completed: data.completed,
createdAt: new Date(data.createdAt),
updatedAt: new Date(data.updatedAt)
});
} catch (error) {
return err("Invalid todo data");
}
};
// ストレージ操作
const storage = {
save: (todos: Todo[]): Result<void, string> => {
try {
const data = todos.map(serializeTodo);
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
return ok(undefined);
} catch (error) {
return err("Failed to save todos");
}
},
load: (): Result<Todo[], string> => {
try {
const json = localStorage.getItem(STORAGE_KEY);
if (!json) return ok([]);
const data = JSON.parse(json);
if (!Array.isArray(data)) return err("Invalid data format");
const results = data.map(deserializeTodo);
const errors = results.filter(isErr);
if (errors.length > 0) {
return err("Some todos could not be loaded");
}
return ok(results.map(r => (r as Ok<Todo>).value));
} catch (error) {
return err("Failed to load todos");
}
}
};
非同期同期のシミュレーション¶
// APIクライアントのモック
const mockApi = {
fetchTodos: (): AsyncResult<Todo[], string> =>
async () => {
// ネットワーク遅延のシミュレーション
await new Promise(resolve => setTimeout(resolve, 1000));
// ランダムにエラーを発生
if (Math.random() < 0.1) {
return err("Network error");
}
// ストレージからデータを取得
return storage.load();
},
saveTodo: (todo: Todo): AsyncResult<Todo, string> =>
async () => {
await new Promise(resolve => setTimeout(resolve, 500));
if (Math.random() < 0.05) {
return err("Failed to save");
}
return ok(todo);
}
};
副作用の管理¶
エフェクトハンドラー¶
// 副作用の定義
type Effect =
| { type: "SAVE_TO_STORAGE"; payload: Todo[] }
| { type: "LOAD_FROM_STORAGE" }
| { type: "SYNC_WITH_SERVER" };
// エフェクトハンドラー
const handleEffect = (
effect: Effect,
dispatch: (action: Action) => void
): void => {
switch (effect.type) {
case "SAVE_TO_STORAGE":
const saveResult = storage.save(effect.payload);
if (isErr(saveResult)) {
dispatch({ type: "SET_ERROR", payload: saveResult.error });
}
break;
case "LOAD_FROM_STORAGE":
const loadResult = storage.load();
if (isOk(loadResult)) {
dispatch({ type: "SET_TODOS", payload: loadResult.value });
} else {
dispatch({ type: "SET_ERROR", payload: loadResult.error });
}
break;
case "SYNC_WITH_SERVER":
dispatch({ type: "SET_LOADING", payload: true });
runTask(mockApi.fetchTodos()).then(result => {
dispatch({ type: "SET_LOADING", payload: false });
if (isOk(result)) {
dispatch({ type: "SET_TODOS", payload: result.value });
} else {
dispatch({ type: "SET_ERROR", payload: result.error });
}
});
break;
}
};
UIコンポーネント(React例)¶
// カスタムフック
const useTodoStore = () => {
const [state, setState] = useState(store.getState());
useEffect(() => {
const unsubscribe = store.subscribe(setState);
return unsubscribe;
}, []);
const dispatch = useCallback((action: Action) => {
store.dispatch(action);
// 自動保存
if (["ADD_TODO", "REMOVE_TODO", "TOGGLE_TODO", "UPDATE_TODO", "TOGGLE_ALL", "CLEAR_COMPLETED"].includes(action.type)) {
handleEffect(
{ type: "SAVE_TO_STORAGE", payload: store.getState().todos },
store.dispatch.bind(store)
);
}
}, []);
return { state, dispatch };
};
// TODOリストコンポーネント
const TodoList: React.FC = () => {
const { state, dispatch } = useTodoStore();
const visibleTodos = filterTodos(state.todos, state.filter);
const stats = calculateStats(state.todos);
const handleAddTodo = (title: string) => {
dispatch({ type: "ADD_TODO", payload: title });
};
const handleToggle = (id: TodoId) => {
dispatch({ type: "TOGGLE_TODO", payload: id });
};
const handleRemove = (id: TodoId) => {
dispatch({ type: "REMOVE_TODO", payload: id });
};
// 初回ロード
useEffect(() => {
handleEffect(
{ type: "LOAD_FROM_STORAGE" },
dispatch
);
}, [dispatch]);
return (
<div>
<TodoInput onAdd={handleAddTodo} />
<TodoItems
todos={visibleTodos}
onToggle={handleToggle}
onRemove={handleRemove}
/>
<TodoFooter
stats={stats}
currentFilter={state.filter}
onFilterChange={(filter) =>
dispatch({ type: "SET_FILTER", payload: filter })
}
onClearCompleted={() =>
dispatch({ type: "CLEAR_COMPLETED" })
}
/>
{state.error && <ErrorMessage message={state.error} />}
{state.isLoading && <LoadingSpinner />}
</div>
);
};
テスト¶
ビジネスロジックのテスト¶
describe("Todo operations", () => {
describe("addTodo", () => {
it("should add a new todo", () => {
const todos: Todo[] = [];
const result = addTodo(todos, "New task");
expect(result).toHaveLength(1);
expect(result[0].title).toBe("New task");
expect(result[0].completed).toBe(false);
});
it("should not add empty todo", () => {
const todos: Todo[] = [];
const result = addTodo(todos, " ");
expect(result).toHaveLength(0);
});
});
describe("toggleTodo", () => {
it("should toggle todo completion", () => {
const todo = createTodo("Test");
const todos = [todo];
const result = toggleTodo(todos, todo.id);
expect(result[0].completed).toBe(true);
expect(todos[0].completed).toBe(false); // 元の配列は変更されない
});
});
});
describe("Reducer", () => {
it("should handle ADD_TODO action", () => {
const state: AppState = {
todos: [],
filter: "all",
isLoading: false,
error: null
};
const newState = reducer(state, {
type: "ADD_TODO",
payload: "New todo"
});
expect(newState.todos).toHaveLength(1);
expect(newState.todos[0].title).toBe("New todo");
});
});
まとめ¶
この実践プロジェクトでは:
- ✅ ビジネスロジックを純粋関数として実装
- ✅ 状態管理を不変データ構造で実現
- ✅ 副作用を明確に分離して管理
- ✅ エラーハンドリングを一貫して実装
- ✅ テスタブルなアーキテクチャを構築
関数型プログラミングの原則に従うことで、予測可能で保守しやすいアプリケーションを構築できました。
発展課題¶
- Undo/Redo機能: 状態の履歴を管理して実装
- 楽観的更新: 非同期操作の改善
- データ検証: より厳密な型とバリデーション
- パフォーマンス最適化: メモ化とセレクターの活用