TypeScriptで関数型プログラミングを設計に取り入れる 純粋関数で堅牢にする手順
TypeScript で設計を進める際、「なぜか状態が壊れる」「テストが書きにくい」「変更のたびにバグが出る」といった悩みに直面したことはないでしょうか。この記事は、純粋関数と TypeScript の型システムを組み合わせ、破綻しにくい設計と実装を段階的に進めたい方に向けて執筆しました。実際に業務でジェネリクスや型エイリアスを活用しながら関数型アプローチを導入した経験をもとに、型推論を活かした堅牢なコード設計の進め方を解説します。
純粋関数と副作用のある関数の比較
| 観点 | 純粋関数 | 副作用のある関数 |
|---|---|---|
| 予測可能性 | 同じ入力に対して常に同じ出力 | 外部状態によって結果が変動 |
| テスト容易性 | モック不要で単体テスト可能 | 外部依存のモックが必要 |
| 型推論との相性 | 型が入出力を完全に表現 | 隠れた依存が型に現れない |
| デバッグ | 入出力だけを確認すれば十分 | 実行順序・状態を追跡が必要 |
| 並行処理 | 競合状態が発生しない | 排他制御が必要な場合がある |
| 設計の柔軟性 | 関数合成で機能を拡張 | 依存関係が複雑化しやすい |
つまずきやすい点:「純粋関数は理想論で実務では使えない」と誤解されやすいですが、副作用を完全になくすのではなく「分離する」ことが重要です。
検証環境
- OS: macOS Sequoia 15.3
- Node.js: 24.11.0 LTS (Krypton)
- TypeScript: 5.9.3
- 主要パッケージ:
- immer: 10.1.1
- fp-ts: 2.16.9
- 検証日: 2026 年 01 月 20 日
純粋関数と型が相性のよい理由
この章では、なぜ TypeScript の型システムと純粋関数が高い親和性を持つのかを整理します。
関数型プログラミングの中核である純粋関数は、「同じ入力に対して常に同じ出力を返し、外部状態を変更しない」という性質を持ちます。この性質は、TypeScript の型システムと非常に相性がよいといえます。
typescript// 型エイリアスで関数の契約を明示
type PriceCalculator = (basePrice: number, taxRate: number) => number;
// 純粋関数:入力と出力が型で完全に表現される
const calculateTax: PriceCalculator = (basePrice, taxRate) =>
basePrice * (1 + taxRate);
// 型推論により戻り値の型が自動的に number と推論される
const result = calculateTax(1000, 0.1); // 1100
純粋関数では、関数の振る舞いが引数と戻り値だけで完結します。そのため、型エイリアスで関数シグネチャを定義すれば、コードを読むだけで何が入力され何が出力されるかが明確になります。
一方、副作用を持つ関数では型だけでは振る舞いを把握できません。
typescript// 副作用のある関数:型だけでは振る舞いがわからない
let totalSales = 0;
const addSale = (amount: number): number => {
totalSales += amount; // 外部状態を変更(副作用)
console.log(`Sale added: ${amount}`); // I/O 操作(副作用)
return totalSales;
};
この関数は (amount: number) => number という型を持ちますが、実際には外部変数 totalSales を変更し、コンソール出力も行います。型だけを見ても、この関数が何回呼ばれたかによって結果が変わることは読み取れません。
つまずきやすい点:副作用のある関数でも TypeScript は型チェックを通しますが、それは「型が正しい」のであって「設計が正しい」とは限りません。
副作用を放置すると設計が破綻する理由
この章では、副作用を意識せずにコードを書き続けた場合に起きる具体的な問題を説明します。
検証中に実際に遭遇した問題として、以下のようなケースがありました。
typescript// 問題のあるコード:状態とロジックが混在
type CartItem = {
id: number;
name: string;
price: number;
quantity: number;
};
const cart: CartItem[] = [];
const addToCart = (item: CartItem): void => {
const existing = cart.find((i) => i.id === item.id);
if (existing) {
existing.quantity += item.quantity; // 既存オブジェクトを直接変更
} else {
cart.push(item); // 配列を直接変更
}
};
const getTotal = (): number =>
cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
このコードには以下の問題があります。
- テストが困難:
cartがグローバル状態のため、テストごとに初期化が必要 - 競合状態のリスク:非同期処理で
addToCartが同時に呼ばれると不整合が発生 - 型推論の限界:
getTotal()の結果がcartの状態に依存することが型から読み取れない
mermaidflowchart LR
subgraph Problem["副作用による問題"]
A["addToCart 呼び出し"] --> B["グローバル cart 変更"]
C["addToCart 呼び出し"] --> B
B --> D["getTotal 呼び出し"]
D --> E["予測不能な結果"]
end
この図は、複数箇所から addToCart が呼ばれた場合に、グローバルな cart 配列が予測不能な状態になる様子を示しています。
実際にこの設計でプロダクションコードを書いた結果、「カートの合計金額が時々おかしくなる」という報告を受け、原因究明に数時間を費やしました。非同期処理の順序によって cart の状態が変わることが原因でした。
純粋関数による設計への移行手順
この章では、副作用のあるコードを純粋関数ベースの設計に移行する具体的な手順を解説します。
手順 1:状態を引数として受け取る
まず、グローバル状態への依存を排除し、状態を関数の引数として明示的に受け取るように変更します。
typescript// 型エイリアスで状態の構造を定義
type Cart = {
readonly items: readonly CartItem[];
};
// 純粋関数:状態を引数で受け取り、新しい状態を返す
const addToCart = (cart: Cart, item: CartItem): Cart => {
const existingIndex = cart.items.findIndex((i) => i.id === item.id);
if (existingIndex >= 0) {
// 既存アイテムの数量を更新(イミュータブルに)
const updatedItems = cart.items.map((cartItem, index) =>
index === existingIndex
? { ...cartItem, quantity: cartItem.quantity + item.quantity }
: cartItem,
);
return { items: updatedItems };
}
return { items: [...cart.items, item] };
};
// 純粋関数:計算ロジックも状態を引数で受け取る
const getTotal = (cart: Cart): number =>
cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
この変更により、関数の入出力が型で完全に表現されます。addToCart は Cart を受け取り Cart を返す、getTotal は Cart を受け取り number を返す、という契約が明確になりました。
手順 2:ジェネリクスで汎用的な更新関数を作成
純粋関数パターンを繰り返し使う場面では、ジェネリクスを活用して汎用的なユーティリティを作成すると便利です。
typescript// ジェネリクスを使った汎用的な配列更新関数
const updateItem = <T>(
items: readonly T[],
predicate: (item: T) => boolean,
updater: (item: T) => T,
): T[] => items.map((item) => (predicate(item) ? updater(item) : item));
// ジェネリクスを使った汎用的な配列追加・更新関数
const upsertItem = <T, K extends keyof T>(
items: readonly T[],
item: T,
keyField: K,
merger: (existing: T, incoming: T) => T,
): T[] => {
const index = items.findIndex((i) => i[keyField] === item[keyField]);
if (index >= 0) {
return updateItem(
items,
(i) => i[keyField] === item[keyField],
(existing) => merger(existing, item),
);
}
return [...items, item];
};
// 使用例:型推論により T が CartItem と推論される
const updatedItems = upsertItem(
cart.items,
newItem,
"id",
(existing, incoming) => ({
...existing,
quantity: existing.quantity + incoming.quantity,
}),
);
ジェネリクスの <T, K extends keyof T> という制約により、keyField には T のプロパティ名しか指定できません。これにより、タイポや存在しないプロパティの指定をコンパイル時に検出できます。
手順 3:副作用を境界に押し出す
純粋関数だけではアプリケーションは完成しません。ファイル読み書き、API 通信、ログ出力などの副作用は必ず発生します。重要なのは、副作用をアプリケーションの「境界」に押し出し、ビジネスロジックを純粋関数として保つことです。
typescript// 純粋関数:ビジネスロジック
const calculateDiscount = (cart: Cart, couponCode: string | null): Cart => {
if (!couponCode) return cart;
const discountRate = couponCode === "SAVE10" ? 0.1 : 0;
const discountedItems = cart.items.map((item) => ({
...item,
price: item.price * (1 - discountRate),
}));
return { items: discountedItems };
};
// 副作用を含む境界層:API 通信
const fetchCoupon = async (
code: string,
): Promise<{ isValid: boolean; discountRate: number }> => {
const response = await fetch(`/api/coupons/${code}`);
return response.json();
};
// 副作用を含む境界層:状態の永続化
const saveCart = async (cart: Cart): Promise<void> => {
await fetch("/api/cart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cart),
});
};
// 境界層でのみ副作用を実行し、純粋関数を組み合わせる
const processCheckout = async (
initialCart: Cart,
couponCode: string | null,
): Promise<{ cart: Cart; total: number }> => {
// 純粋関数による計算
const discountedCart = calculateDiscount(initialCart, couponCode);
const total = getTotal(discountedCart);
// 副作用は最後にまとめて実行
await saveCart(discountedCart);
return { cart: discountedCart, total };
};
mermaidflowchart TB
subgraph Boundary["境界層(副作用あり)"]
API["API 通信"]
DB["データ永続化"]
LOG["ログ出力"]
end
subgraph Core["コア(純粋関数)"]
Calc["calculateDiscount"]
Total["getTotal"]
Update["updateItem"]
end
API --> Core
Core --> DB
Core --> LOG
この図は、副作用を境界層に分離し、コアのビジネスロジックを純粋関数として保つアーキテクチャを示しています。
型エイリアスと型推論を活用した堅牢な設計
この章では、型エイリアスと型推論を組み合わせて、より堅牢な設計を実現する方法を解説します。
型エイリアスによる関数シグネチャの明示
関数型プログラミングでは、関数を値として扱う場面が頻繁にあります。型エイリアスで関数の型を定義しておくと、コードの可読性と保守性が向上します。
typescript// 汎用的なバリデータの型エイリアス
type Validator<T> = (value: T) => ValidationResult<T>;
type ValidationResult<T> =
| { success: true; value: T }
| { success: false; errors: string[] };
// 型エイリアスを使った関数定義
const required: Validator<string> = (value) =>
value.trim().length > 0
? { success: true, value }
: { success: false, errors: ["必須項目です"] };
const minLength =
(min: number): Validator<string> =>
(value) =>
value.length >= min
? { success: true, value }
: { success: false, errors: [`${min}文字以上で入力してください`] };
const email: Validator<string> = (value) =>
value.includes("@")
? { success: true, value }
: { success: false, errors: ["メールアドレスの形式が正しくありません"] };
型推論を活かした関数合成
TypeScript の型推論は、関数を合成する際に威力を発揮します。各関数の型が正しく推論されるため、合成後の関数の型も自動的に決定されます。
typescript// 関数合成のユーティリティ
const pipe =
<T>(...fns: Array<(arg: T) => T>) =>
(value: T): T =>
fns.reduce((acc, fn) => fn(acc), value);
// バリデータを合成する関数
const combineValidators = <T>(...validators: Validator<T>[]): Validator<T> => {
return (value: T): ValidationResult<T> => {
const errors: string[] = [];
for (const validate of validators) {
const result = validate(value);
if (!result.success) {
errors.push(...result.errors);
}
}
return errors.length > 0
? { success: false, errors }
: { success: true, value };
};
};
// 型推論により Validator<string> と推論される
const validateEmail = combineValidators(required, minLength(5), email);
// 使用例
const result = validateEmail("test@example.com");
// result の型は ValidationResult<string> と推論される
readonly と const assertions による不変性の強制
TypeScript では、readonly 修飾子と as const アサーションを使って、型レベルで不変性を強制できます。
typescript// readonly で型レベルの不変性を表現
type ImmutableCart = {
readonly items: readonly CartItem[];
readonly createdAt: Date;
readonly updatedAt: Date;
};
// as const で厳密なリテラル型を取得
const STATUS = {
PENDING: "pending",
PROCESSING: "processing",
COMPLETED: "completed",
CANCELLED: "cancelled",
} as const;
// Status 型は 'pending' | 'processing' | 'completed' | 'cancelled' となる
type Status = (typeof STATUS)[keyof typeof STATUS];
// 純粋関数で状態遷移を表現
type Order = {
readonly id: number;
readonly status: Status;
readonly items: readonly CartItem[];
};
const transitionOrder = (order: Order, newStatus: Status): Order => ({
...order,
status: newStatus,
});
つまずきやすい点:
readonlyは TypeScript の型チェック時のみ有効で、ランタイムでの変更は防げません。ランタイムでも不変性を保証したい場合はObject.freezeを併用するか、Immer のようなライブラリを使用します。
Maybe/Either 型による安全なエラーハンドリング
この章では、関数型プログラミングで頻繁に使われる Maybe 型と Either 型を TypeScript で実装し、安全なエラーハンドリングを実現する方法を解説します。
Maybe 型による null 安全の実現
Maybe 型(Option 型とも呼ばれます)は、値が存在しない可能性を型で表現するパターンです。
typescript// Maybe 型の定義
type Maybe<T> = { kind: "some"; value: T } | { kind: "none" };
// ヘルパー関数
const some = <T>(value: T): Maybe<T> => ({ kind: "some", value });
const none = <T>(): Maybe<T> => ({ kind: "none" });
// Maybe 型を操作する純粋関数
const map = <T, U>(maybe: Maybe<T>, fn: (value: T) => U): Maybe<U> =>
maybe.kind === "some" ? some(fn(maybe.value)) : none();
const flatMap = <T, U>(
maybe: Maybe<T>,
fn: (value: T) => Maybe<U>,
): Maybe<U> => (maybe.kind === "some" ? fn(maybe.value) : none());
const getOrElse = <T>(maybe: Maybe<T>, defaultValue: T): T =>
maybe.kind === "some" ? maybe.value : defaultValue;
// 実用例:安全な値の取得
const safeParseInt = (str: string): Maybe<number> => {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? none() : some(parsed);
};
const safeDivide = (a: number, b: number): Maybe<number> =>
b === 0 ? none() : some(a / b);
// 関数合成による安全な計算
const calculatePercentage = (
numerator: string,
denominator: string,
): Maybe<string> => {
const num = safeParseInt(numerator);
const den = safeParseInt(denominator);
if (num.kind === "none" || den.kind === "none") {
return none();
}
return map(
safeDivide(num.value, den.value),
(ratio) => `${(ratio * 100).toFixed(2)}%`,
);
};
Either 型による詳細なエラー情報の伝播
Maybe 型は「値があるかないか」しか表現できません。エラーの詳細情報を伝えたい場合は Either 型を使います。
typescript// Either 型の定義
type Either<E, T> = { kind: "left"; error: E } | { kind: "right"; value: T };
// ヘルパー関数
const left = <E, T>(error: E): Either<E, T> => ({
kind: "left",
error,
});
const right = <E, T>(value: T): Either<E, T> => ({
kind: "right",
value,
});
// Either 型を操作する純粋関数
const mapEither = <E, T, U>(
either: Either<E, T>,
fn: (value: T) => U,
): Either<E, U> =>
either.kind === "right" ? right(fn(either.value)) : left(either.error);
const flatMapEither = <E, T, U>(
either: Either<E, T>,
fn: (value: T) => Either<E, U>,
): Either<E, U> =>
either.kind === "right" ? fn(either.value) : left(either.error);
// 実用例:詳細なエラー情報を持つバリデーション
type ValidationError = {
field: string;
message: string;
code: string;
};
const validateAge = (age: number): Either<ValidationError, number> =>
age >= 0 && age <= 150
? right(age)
: left({
field: "age",
message: "年齢は 0 から 150 の間で入力してください",
code: "INVALID_AGE_RANGE",
});
const validateUsername = (
username: string,
): Either<ValidationError, string> => {
if (username.length < 3) {
return left({
field: "username",
message: "ユーザー名は 3 文字以上で入力してください",
code: "USERNAME_TOO_SHORT",
});
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return left({
field: "username",
message: "ユーザー名は英数字とアンダースコアのみ使用できます",
code: "INVALID_USERNAME_FORMAT",
});
}
return right(username);
};
純粋関数と副作用のある関数の使い分け(詳細)
| 観点 | 純粋関数を選ぶケース | 副作用のある関数が必要なケース |
|---|---|---|
| データ変換 | 入力を加工して出力を生成 | - |
| 計算ロジック | 数値計算、文字列処理 | - |
| バリデーション | 入力値の検証 | - |
| API 通信 | - | fetch、axios など |
| データ永続化 | - | DB 操作、ファイル I/O |
| ログ出力 | - | console.log、ロガー |
| 状態管理 | 状態遷移の計算 | 状態の読み書き |
| 時刻取得 | - | Date.now()、new Date() |
| 乱数生成 | - | Math.random() |
設計の指針:
- ビジネスロジックは純粋関数で実装:計算、変換、バリデーションは純粋関数
- 副作用は境界層に集約:API 通信、DB 操作、ログは専用のレイヤーで
- 副作用のある関数は明示的に命名:
fetch〜、save〜、log〜など - 純粋関数と副作用の混在を避ける:1 つの関数で両方を行わない
関数合成による設計の拡張性
この章では、関数合成を活用して拡張性の高い設計を実現する方法を解説します。
typescript// 関数合成のユーティリティ(型推論を活かした実装)
const compose =
<A, B, C>(f: (b: B) => C, g: (a: A) => B) =>
(a: A): C =>
f(g(a));
// パイプライン(左から右への合成)
const pipeline = <T>(...fns: Array<(arg: T) => T>): ((arg: T) => T) =>
fns.reduce(
(composed, fn) => (arg: T) => fn(composed(arg)),
(arg: T) => arg,
);
// 実用例:注文処理パイプライン
type Order = {
readonly id: number;
readonly items: readonly CartItem[];
readonly subtotal: number;
readonly tax: number;
readonly discount: number;
readonly total: number;
};
// 各処理を純粋関数として定義
const calculateSubtotal = (order: Order): Order => ({
...order,
subtotal: order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
),
});
const applyTax =
(taxRate: number) =>
(order: Order): Order => ({
...order,
tax: order.subtotal * taxRate,
});
const applyDiscount =
(discountRate: number) =>
(order: Order): Order => ({
...order,
discount: order.subtotal * discountRate,
});
const calculateTotal = (order: Order): Order => ({
...order,
total: order.subtotal + order.tax - order.discount,
});
// パイプラインで関数を合成
const processOrder = pipeline(
calculateSubtotal,
applyTax(0.1),
applyDiscount(0.05),
calculateTotal,
);
// 使用例
const initialOrder: Order = {
id: 1,
items: [
{ id: 1, name: "商品A", price: 1000, quantity: 2 },
{ id: 2, name: "商品B", price: 500, quantity: 3 },
],
subtotal: 0,
tax: 0,
discount: 0,
total: 0,
};
const finalOrder = processOrder(initialOrder);
// subtotal: 3500, tax: 350, discount: 175, total: 3675
mermaidflowchart LR
A["初期 Order"] --> B["calculateSubtotal"]
B --> C["applyTax"]
C --> D["applyDiscount"]
D --> E["calculateTotal"]
E --> F["最終 Order"]
この図は、パイプラインによる関数合成の流れを示しています。各関数は独立してテスト可能であり、処理の追加・削除・順序変更も容易です。
実際に試したところ、この設計では新しい割引ルールの追加が「新しい純粋関数を 1 つ作ってパイプラインに追加する」だけで完了しました。従来の設計では条件分岐が増えてテストが困難になっていた部分が、大幅に改善されました。
まとめ
純粋関数と TypeScript の型システムを組み合わせることで、予測可能でテストしやすく、保守性の高いコードを実現できます。
ただし、すべてのコードを純粋関数にする必要はありません。重要なのは以下の点です。
- ビジネスロジックを純粋関数として分離:計算、変換、バリデーションは純粋関数で
- 副作用をアプリケーションの境界に押し出す:API 通信、DB 操作、ログは境界層で
- 型エイリアスとジェネリクスで設計意図を表現:関数シグネチャを明示する
- 型推論を活かして冗長な型注釈を減らす:TypeScript の推論能力を信頼する
関数型プログラミングの導入は段階的に行うことをおすすめします。まずは 1 つのモジュールで純粋関数パターンを試し、効果を実感してから徐々に適用範囲を広げていくのが現実的なアプローチです。
関連リンク
著書
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月20日TypeScriptで関数型プログラミングを設計に取り入れる 純粋関数で堅牢にする手順
article2026年1月18日TypeScriptで非同期処理を型安全に書く使い方 Promiseとasync awaitの型定義を整理
article2026年1月16日TypeScriptの高度な型操作を使い方で理解する keyof typeof inferを実例で整理
article2026年1月16日TypeScriptでFunction Overloadsを設計に使う 柔軟なAPIパターンと使い分け
article2026年1月13日TypeScriptでHigher Kinded Typesを模倣する設計 ジェネリクスで関数型パターンを整理
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
article2026年1月23日TypeScript 5.8の型推論を比較・検証する 強化点と落とし穴の回避策
article2026年1月23日TypeScript Genericsの使用例を早見表でまとめる 記法と頻出パターンを整理
article2026年1月22日TypeScriptの型システムを概要で理解する 基礎から全体像まで完全解説
article2026年1月22日ZustandとTypeScriptのユースケース ストアを型安全に設計して運用する実践
article2026年1月22日TypeScriptでよく出るエラーをトラブルシュートでまとめる 原因と解決法30選
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
