関数型プログラミングでクリーンコードを実現:JS/TSにおけるカリー化、パイプ、コンポジション

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

「絡まった糸」問題

以前、2,000行もある単一のファイルで、5つの異なる関数からグローバル変数が書き換えられている箇所のデバッグに丸3日を費やしたことがあります。暗闇の中で古いクリスマスライトの箱を解こうとしているような気分でした。一箇所のバグを直すたびに、別の場所で2つの新しいバグが発生しました。その瞬間、命令型プログラミングはクイックスクリプトには最適ですが、大規模なアプリケーションの重みには耐えられないことが多いのだと痛感しました。

関数型プログラミング(FP)は、Haskell愛好家のためだけの学術的な理論ではありません。データフローを管理するための実用的なツールキットです。コンピュータの状態をステップバイステップで細かく管理する代わりに、データがどのように変換されるべきかを記述します。私の経験上、この考え方に移行することは、コードを少し触るたびにシステムが壊れないようにするための最も効果的な方法です。

カリー化、パイプ、コンポジションを採用することで、「スパゲッティコード」を「レゴブロック」のようなアーキテクチャに置き換えることができます。各関数は、小さく予測可能で、独立したユニットになります。JavaScriptとTypeScriptでこれらのパターンを実装し、開発のストレスを軽減する方法を見ていきましょう。

成功のための準備

FPを始めるために巨大なライブラリは必要ありません。ただし、TypeScriptの使用を強くお勧めします。その型システムはガードレールの役割を果たし、関数A의出力が実際に関数Bの入力に適合することを保証します。これにより、プロダクション環境に届く前に undefined is not a function という不快なエラーを防ぐことができます。

プレイグラウンドを動かすために、ターミナルでクリーンなTypeScriptプロジェクトを初期化しましょう:

# プロジェクトフォルダを作成
mkdir fp-lab && cd fp-lab

# npmのクイックセットアップ
npm init -y

# 必要なパッケージをインストール
npm install typescript ts-node @types/node --save-dev

# 設定ファイルを生成
npx tsc --init

index.ts ファイルを作成し、 ts-node を使ってコードを実行します。この軽量なセットアップは、完全なビルドパイプラインのオーバーヘッドなしにロジックをスケッチするのに最適です。

コア・ユーティリティ

コアとなるツールを一から構築してみましょう。これらのパターンの背後にある「なぜ」を理解することで、実際のプロジェクトで格段に使いやすくなります。

1. カリー化:ロジックの事前注入

カリー化は、複数の引数を持つ関数を、一度に1つの引数を取る一連の関数に変換します。汎用的なツールから特殊なツールを作るのに最適です。例えば、通知システムを構築しているとしましょう。ログを記録するたびに重要度(severity level)を渡したくはないはずです。

// 汎用バージョン
const log = (level: string, message: string) => {
  console.log(`[${new Date().toISOString()}] [${level}] ${message}`);
};

// カリー化バージョン
const curriedLog = (level: string) => (message: string) => {
  console.log(`[${level}] ${message}`);
};

const criticalError = curriedLog("CRITICAL");
const userNotice = curriedLog("NOTICE");

criticalError("データベース接続が失われました!"); // [CRITICAL] データベース接続が失われました!
userNotice("ユーザーがログインしました。");      // [NOTICE] ユーザーがログインしました。

2. 関数の合成(Composition):チェーンの構築

合成(Composition)は、FPの中でも数学的な側面が強い手法です。複数の関数を組み合わせ、 compose(f, g)(x)f(g(x)) のように動作するようにします。データは右から左へと流れ、これは数学的な純粋さを保つのに適しています。

const double = (n: number) => n * 2;
const plusTen = (n: number) => n + 10;

// 右から左への流れ
const compose = <T>(...fns: Array<(arg: T) => T>) => 
  (value: T) => fns.reduceRight((acc, fn) => fn(acc), value);

const processValue = compose(double, plusTen);
console.log(processValue(5)); // (5 + 10) * 2 = 30

3. パイプライン思考(Pipe)

多くの開発者は、 compose よりも pipe を好みます。なぜなら、英語のように左から右へと読めるため直感的だからです。データを受け取り、それを一連のステーションに通していくイメージです。各ステーションでデータを少しずつ変更し、次のステーションへと渡します。

const pipe = <T>(...fns: Array<(arg: T) => T>) => 
  (value: T) => fns.reduce((acc, fn) => fn(acc), value);

// 文字列クリーンアップのパイプライン
const trim = (s: string) => s.trim();
const shout = (s: string) => s.toUpperCase();
const tag = (s: string) => `[LOG]: ${s}`;

const prepareHeader = pipe(trim, shout, tag);

console.log(prepareHeader("   welcome home   ")); 
// "[LOG]: WELCOME HOME"

本番環境では、ReduxのreducerやExpressのミドルウェアなどで複雑なデータの変換を処理するために pipe を使用します。これによりロジックがフラットに保たれます。1行の終わりに5つの閉じ括弧が並ぶような「死のピラミッド」を避けることができます。

テストとオブザーバビリティ(観測容易性)

FPを導入すると、テストはほとんど退屈な作業になります。純粋関数はグローバルな状態を触らないため、複雑なモックbeforeEach でのリセットが必要ありません。すべてのテストは、単純な入力と出力のチェックになります。

import { describe, it, expect } from 'vitest';

describe('FPパイプライン', () => {
  it('予測可能な方法で文字列を変換すること', () => {
    const output = prepareHeader(" test ");
    expect(output).toBe("[LOG]: TEST");
  });
});

パイプラインのデバッグ

pipe に関してよく聞かれる不満は、チェーン의途中にブレークポイントを簡単に設定できないことです。これを解決するために、私は trace ヘルパーを使用しています。これは値をログに出力し、変更を加えずにそのまま次の関数に渡します。

const trace = <T>(label: string) => (value: T) => {
  console.log(`${label}:`, value);
  return value;
};

const debugPipeline = pipe(
  trim,
  trace("トリム後"),
  shout,
  trace("大文字変換後"),
  tag
);

このアプローチにより、デバッグはデータの進化を体系的に観察する作業へと変わります。500行もあるクラスの中に隠れた不正な状態変化を探し回る必要はもうありません。代わりに、すべてのステップでデータがどのように成長し変化するかを正確に把握できます。まずは小さなユーティリティファイルのリファクタリングから始めてみてください。次回のコードレビューで見違えるほど明快になったコードは、その労力に見合う価値があるはずです。

Share: