T-CREATOR

<div />

TypeScriptで関数型プログラミングを設計に取り入れる 純粋関数で堅牢にする手順

2026年1月20日
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);

このコードには以下の問題があります。

  1. テストが困難cart がグローバル状態のため、テストごとに初期化が必要
  2. 競合状態のリスク:非同期処理で addToCart が同時に呼ばれると不整合が発生
  3. 型推論の限界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);

この変更により、関数の入出力が型で完全に表現されます。addToCartCart を受け取り Cart を返す、getTotalCart を受け取り 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()

設計の指針

  1. ビジネスロジックは純粋関数で実装:計算、変換、バリデーションは純粋関数
  2. 副作用は境界層に集約:API 通信、DB 操作、ログは専用のレイヤーで
  3. 副作用のある関数は明示的に命名fetch〜save〜log〜 など
  4. 純粋関数と副作用の混在を避ける: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 つのモジュールで純粋関数パターンを試し、効果を実感してから徐々に適用範囲を広げていくのが現実的なアプローチです。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;