はじめに

「関数型プログラミング」という言葉を聞いたことはあるでしょうか。近年のJavaScript開発では、ReactやReduxなどのライブラリ・フレームワークが関数型プログラミングの考え方を採用しており、その重要性が高まっています。

関数型プログラミングは、データの変更を避け、純粋な関数を組み合わせてプログラムを構築するパラダイムです。このアプローチを取り入れることで、バグが少なく、テストしやすく、保守性の高いコードを書けるようになります。

本記事では、以下の内容を初心者向けにわかりやすく解説します。

  • 関数型プログラミングとは何か
  • イミュータブル(不変性)の考え方
  • 純粋関数と副作用
  • 高階関数とmap/filter/reduceの活用
  • 関数合成とポイントフリースタイル
  • カリー化と部分適用
  • 実践的な活用パターン

関数型プログラミングとは

命令型と宣言型の違い

プログラミングのパラダイムには、大きく分けて「命令型」と「宣言型」があります。関数型プログラミングは宣言型の一種です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const numbers = [1, 2, 3, 4, 5];

// 命令型:「どうやって」処理するかを記述
const doubledImperative = [];
for (let i = 0; i < numbers.length; i++) {
  doubledImperative.push(numbers[i] * 2);
}

// 宣言型(関数型):「何を」したいかを記述
const doubledDeclarative = numbers.map((n) => n * 2);

console.log(doubledImperative); // [2, 4, 6, 8, 10]
console.log(doubledDeclarative); // [2, 4, 6, 8, 10]

命令型のコードは、ループカウンタの初期化、条件判定、インクリメントなど「処理の手順」を細かく記述します。一方、宣言型のコードは「各要素を2倍にする」という意図を直接表現しています。

関数型プログラミングの主要な特徴

関数型プログラミングには、以下の主要な特徴があります。

特徴 説明
純粋関数 同じ入力に対して常に同じ出力を返し、副作用を持たない
イミュータブル データを変更せず、新しいデータを作成する
高階関数 関数を引数として受け取り、関数を返す
関数合成 小さな関数を組み合わせて大きな処理を構築する
宣言的な記述 「何を」したいかを明確に表現する

これらの特徴を理解し実践することで、より堅牢なコードを書けるようになります。

関数型プログラミングのメリット

関数型プログラミングを採用することで、以下のようなメリットが得られます。

メリット 理由
バグの減少 データを変更しないため、意図しない状態変化が起きにくい
テストしやすさ 純粋関数は入出力のテストだけで検証できる
並行処理の安全性 共有状態を変更しないため、競合が発生しにくい
コードの再利用性 小さな関数を組み合わせるため、再利用しやすい
可読性の向上 宣言的な記述により、意図が明確になる

イミュータブル(不変性)の考え方

ミュータブルとイミュータブル

イミュータブル(Immutable)とは、一度作成したデータを変更しないという考え方です。逆に、データを直接変更できることをミュータブル(Mutable)と呼びます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ミュータブル(変更可能)な操作
const mutableArray = [1, 2, 3];
mutableArray.push(4); // 元の配列を変更
console.log(mutableArray); // [1, 2, 3, 4]

// イミュータブル(不変)な操作
const immutableArray = [1, 2, 3];
const newArray = [...immutableArray, 4]; // 新しい配列を作成
console.log(immutableArray); // [1, 2, 3] (元の配列は変わらない)
console.log(newArray); // [1, 2, 3, 4]

なぜイミュータブルが重要なのか

データを直接変更すると、以下のような問題が発生しやすくなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 問題のあるコード例
function addDiscount(cart) {
  cart.total = cart.total * 0.9; // 元のオブジェクトを変更
  return cart;
}

const originalCart = { items: ["商品A", "商品B"], total: 1000 };
const discountedCart = addDiscount(originalCart);

console.log(originalCart.total); // 900(意図せず変更されている)
console.log(discountedCart.total); // 900

上記の例では、addDiscount関数が元のオブジェクトを直接変更してしまうため、関数を呼び出す側が意図しない形でデータが書き換わってしまいます。

イミュータブルな操作を実現する方法

JavaScriptでイミュータブルな操作を実現するには、スプレッド構文やイミュータブルな配列メソッドを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// オブジェクトのイミュータブルな更新
function addDiscountImmutable(cart) {
  return {
    ...cart,
    total: cart.total * 0.9 // 新しいオブジェクトを作成
  };
}

const originalCart = { items: ["商品A", "商品B"], total: 1000 };
const discountedCart = addDiscountImmutable(originalCart);

console.log(originalCart.total); // 1000(元のオブジェクトは変わらない)
console.log(discountedCart.total); // 900

// 配列のイミュータブルな操作
const numbers = [1, 2, 3];

// 要素の追加
const added = [...numbers, 4]; // [1, 2, 3, 4]

// 要素の削除
const removed = numbers.filter((n) => n !== 2); // [1, 3]

// 要素の更新
const updated = numbers.map((n) => (n === 2 ? 20 : n)); // [1, 20, 3]

console.log(numbers); // [1, 2, 3](元の配列は変わらない)

ミュータブルなメソッドとイミュータブルなメソッド

JavaScriptの配列メソッドには、元の配列を変更するものと、新しい配列を返すものがあります。

操作 ミュータブル(避ける) イミュータブル(推奨)
末尾に追加 push() [...array, item]
先頭に追加 unshift() [item, ...array]
削除 splice() filter()
ソート sort() [...array].sort()
反転 reverse() [...array].reverse()
更新 直接代入 map()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ソートをイミュータブルに行う
const scores = [85, 92, 78, 95, 88];

// ミュータブル(元の配列を変更)
// scores.sort((a, b) => b - a); // 元の配列が変わってしまう

// イミュータブル(新しい配列を作成)
const sortedScores = [...scores].sort((a, b) => b - a);

console.log(scores); // [85, 92, 78, 95, 88](変わらない)
console.log(sortedScores); // [95, 92, 88, 85, 78]

純粋関数と副作用

純粋関数とは

純粋関数(Pure Function)とは、以下の2つの条件を満たす関数です。

  1. 同じ入力に対して常に同じ出力を返す(参照透過性)
  2. 副作用を持たない(外部の状態を変更しない)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 純粋関数の例
function add(a, b) {
  return a + b;
}

console.log(add(2, 3)); // 常に5を返す
console.log(add(2, 3)); // 何度呼んでも5を返す

// 純粋関数:配列の合計を計算
function sum(numbers) {
  return numbers.reduce((acc, n) => acc + n, 0);
}

console.log(sum([1, 2, 3])); // 常に6を返す

副作用とは

副作用(Side Effect)とは、関数が外部の状態を変更したり、外部の状態に依存したりすることです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 副作用のある関数の例
let counter = 0;

function incrementCounter() {
  counter++; // 外部変数を変更している(副作用)
  return counter;
}

console.log(incrementCounter()); // 1
console.log(incrementCounter()); // 2(同じ呼び出しでも結果が異なる)

// 外部状態に依存する関数
let taxRate = 0.1;

function calculateTax(price) {
  return price * taxRate; // 外部変数に依存している
}

taxRate = 0.08;
console.log(calculateTax(1000)); // 80(税率の変更で結果が変わる)

純粋関数に書き換える

副作用のある関数を純粋関数に書き換えることで、予測可能で信頼性の高いコードになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 純粋関数に書き換え:カウンター
function incrementCounter(counter) {
  return counter + 1; // 新しい値を返す
}

let count = 0;
count = incrementCounter(count); // 1
count = incrementCounter(count); // 2

// 純粋関数に書き換え:税金計算
function calculateTax(price, taxRate) {
  return price * taxRate; // 必要な値を引数で受け取る
}

console.log(calculateTax(1000, 0.1)); // 100
console.log(calculateTax(1000, 0.08)); // 80

副作用を完全に排除することはできない

実際のアプリケーションでは、副作用を完全に排除することはできません。以下のような操作は必ず副作用を伴います。

  • DOM操作
  • API通信
  • ファイルの読み書き
  • コンソールへの出力
  • データベースへのアクセス

関数型プログラミングでは、副作用を排除するのではなく、副作用を局所化して管理することが重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 副作用を局所化する例
// 純粋な関数(ビジネスロジック)
function formatUserData(user) {
  return {
    displayName: `${user.lastName} ${user.firstName}`,
    age: calculateAge(user.birthDate)
  };
}

// 副作用を含む関数(外部とのインタラクション)
async function displayUserInfo(userId) {
  const user = await fetchUser(userId); // 副作用:API通信
  const formatted = formatUserData(user); // 純粋関数を使用
  console.log(formatted); // 副作用:コンソール出力
}

高階関数とmap/filter/reduceの活用

関数型プログラミングでは、高階関数(Higher-Order Function)を多用します。高階関数とは、関数を引数として受け取る関数、または関数を戻り値として返す関数のことです。

map - 変換

mapは配列の各要素を変換し、新しい配列を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const users = [
  { name: "田中", score: 85 },
  { name: "佐藤", score: 92 },
  { name: "鈴木", score: 78 }
];

// 各ユーザーの名前だけを抽出
const names = users.map((user) => user.name);
console.log(names); // ["田中", "佐藤", "鈴木"]

// スコアを10点加算
const bonusScores = users.map((user) => ({
  ...user,
  score: user.score + 10
}));
console.log(bonusScores);
// [
//   { name: "田中", score: 95 },
//   { name: "佐藤", score: 102 },
//   { name: "鈴木", score: 88 }
// ]

filter - 抽出

filterは条件に一致する要素だけを抽出し、新しい配列を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const products = [
  { name: "商品A", price: 500, inStock: true },
  { name: "商品B", price: 1200, inStock: false },
  { name: "商品C", price: 800, inStock: true },
  { name: "商品D", price: 2000, inStock: true }
];

// 在庫がある商品だけを抽出
const availableProducts = products.filter((product) => product.inStock);

// 1000円以下の商品を抽出
const affordableProducts = products.filter((product) => product.price <= 1000);

console.log(availableProducts);
// [{ name: "商品A", ... }, { name: "商品C", ... }, { name: "商品D", ... }]

console.log(affordableProducts);
// [{ name: "商品A", ... }, { name: "商品C", ... }]

reduce - 集約

reduceは配列の全要素を処理し、単一の値(または新しい構造)を返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const orders = [
  { product: "商品A", quantity: 2, unitPrice: 500 },
  { product: "商品B", quantity: 1, unitPrice: 1200 },
  { product: "商品C", quantity: 3, unitPrice: 300 }
];

// 合計金額を計算
const totalAmount = orders.reduce((sum, order) => {
  return sum + order.quantity * order.unitPrice;
}, 0);

console.log(totalAmount); // 2600

// オブジェクトへの変換
const productMap = orders.reduce((map, order) => {
  map[order.product] = order.quantity * order.unitPrice;
  return map;
}, {});

console.log(productMap);
// { "商品A": 1000, "商品B": 1200, "商品C": 900 }

メソッドチェーン

mapfilterreduceはメソッドチェーンで組み合わせることができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const transactions = [
  { type: "income", amount: 5000 },
  { type: "expense", amount: 1200 },
  { type: "income", amount: 3000 },
  { type: "expense", amount: 800 },
  { type: "income", amount: 2000 }
];

// 収入の合計を計算
const totalIncome = transactions
  .filter((t) => t.type === "income") // 収入だけを抽出
  .map((t) => t.amount) // 金額だけを取り出す
  .reduce((sum, amount) => sum + amount, 0); // 合計を計算

console.log(totalIncome); // 10000

関数合成とポイントフリースタイル

関数合成とは

関数合成(Function Composition)とは、複数の関数を組み合わせて新しい関数を作ることです。数学の合成関数 $f(g(x))$ と同じ考え方です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 個別の関数を定義
const double = (x) => x * 2;
const addTen = (x) => x + 10;
const square = (x) => x * x;

// 手動で合成
const result = square(addTen(double(5)));
console.log(result); // 400(5 → 10 → 20 → 400)

// compose関数を作成
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);

// 関数を合成して新しい関数を作成
const calculate = compose(square, addTen, double);
console.log(calculate(5)); // 400

pipe関数

composeは右から左へ関数を適用しますが、pipeは左から右へ適用します。データの流れが直感的に理解しやすくなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

// 左から右へ処理が流れる
const processData = pipe(
  (str) => str.trim(),
  (str) => str.toLowerCase(),
  (str) => str.split(" "),
  (arr) => arr.filter((word) => word.length > 3)
);

const result = processData("  Hello World JavaScript  ");
console.log(result); // ["hello", "world", "javascript"]

ポイントフリースタイル

ポイントフリースタイル(Point-Free Style)とは、関数の引数を明示的に書かないスタイルのことです。関数合成と相性が良く、コードをより抽象的に表現できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 通常のスタイル(引数を明示)
const doubleAll = (numbers) => numbers.map((n) => n * 2);

// ポイントフリースタイル
const double = (n) => n * 2;
const doubleAllPointFree = (numbers) => numbers.map(double);

// さらにポイントフリーに
const map = (fn) => (arr) => arr.map(fn);
const doubleAllFull = map(double);

console.log(doubleAllFull([1, 2, 3])); // [2, 4, 6]

カリー化と部分適用

カリー化とは

カリー化(Currying)とは、複数の引数を取る関数を、1つの引数を取る関数の連鎖に変換することです。名前は数学者のハスケル・カリーに由来します。

1
2
3
4
5
6
7
// 通常の関数(複数の引数を一度に受け取る)
const add = (a, b, c) => a + b + c;
console.log(add(1, 2, 3)); // 6

// カリー化された関数(1つずつ引数を受け取る)
const curriedAdd = (a) => (b) => (c) => a + b + c;
console.log(curriedAdd(1)(2)(3)); // 6

カリー化のメリット

カリー化を使うと、関数の再利用性が高まり、部分適用が容易になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// カリー化された関数
const multiply = (a) => (b) => a * b;

// 部分適用:特定の値を固定した新しい関数を作成
const double = multiply(2);
const triple = multiply(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

// 配列操作での活用
const numbers = [1, 2, 3, 4, 5];
console.log(numbers.map(double)); // [2, 4, 6, 8, 10]
console.log(numbers.map(triple)); // [3, 6, 9, 12, 15]

実践的なカリー化の例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// フィルタ条件をカリー化
const filterBy = (key) => (value) => (arr) =>
  arr.filter((item) => item[key] === value);

const products = [
  { name: "商品A", category: "電化製品", status: "available" },
  { name: "商品B", category: "衣類", status: "soldout" },
  { name: "商品C", category: "電化製品", status: "available" },
  { name: "商品D", category: "衣類", status: "available" }
];

// 部分適用で再利用可能なフィルタを作成
const filterByCategory = filterBy("category");
const filterByElectronics = filterByCategory("電化製品");
const filterByStatus = filterBy("status");
const filterByAvailable = filterByStatus("available");

console.log(filterByElectronics(products));
// [{ name: "商品A", ... }, { name: "商品C", ... }]

console.log(filterByAvailable(products));
// [{ name: "商品A", ... }, { name: "商品C", ... }, { name: "商品D", ... }]

汎用的なカリー化ユーティリティ

任意の関数をカリー化するユーティリティ関数を作成することもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 汎用的なカリー化関数
const curry = (fn) => {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return (...nextArgs) => curried(...args, ...nextArgs);
  };
};

// 使用例
const sum = (a, b, c) => a + b + c;
const curriedSum = curry(sum);

console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6
console.log(curriedSum(1)(2, 3)); // 6
console.log(curriedSum(1, 2, 3)); // 6

実践的な活用パターン

パターン1: データ変換パイプライン

APIから取得したデータを加工する際に、関数型のパターンが活躍します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// APIレスポンスのデータ変換
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);

// 各処理を関数として定義
const filterActiveUsers = (users) =>
  users.filter((user) => user.status === "active");

const sortByRegistration = (users) =>
  [...users].sort((a, b) => new Date(b.registeredAt) - new Date(a.registeredAt));

const takeFirst = (n) => (users) => users.slice(0, n);

const formatForDisplay = (users) =>
  users.map((user) => ({
    id: user.id,
    displayName: `${user.lastName} ${user.firstName}`,
    email: user.email
  }));

// パイプラインを構築
const processUsers = pipe(
  filterActiveUsers,
  sortByRegistration,
  takeFirst(10),
  formatForDisplay
);

// 使用例
const rawUsers = [
  { id: 1, firstName: "太郎", lastName: "田中", status: "active", email: "tanaka@example.com", registeredAt: "2024-01-15" },
  { id: 2, firstName: "花子", lastName: "佐藤", status: "inactive", email: "sato@example.com", registeredAt: "2024-03-20" },
  { id: 3, firstName: "次郎", lastName: "鈴木", status: "active", email: "suzuki@example.com", registeredAt: "2024-02-10" }
];

const displayUsers = processUsers(rawUsers);
console.log(displayUsers);
// [
//   { id: 3, displayName: "鈴木 次郎", email: "suzuki@example.com" },
//   { id: 1, displayName: "田中 太郎", email: "tanaka@example.com" }
// ]

パターン2: バリデーション

複数のバリデーションルールを合成するパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// バリデーション関数を作成
const createValidator = (predicate, errorMessage) => (value) =>
  predicate(value) ? { valid: true } : { valid: false, error: errorMessage };

// 個別のバリデータ
const isRequired = createValidator(
  (value) => value !== null && value !== undefined && value !== "",
  "必須項目です"
);

const minLength = (min) =>
  createValidator(
    (value) => value.length >= min,
    `${min}文字以上で入力してください`
  );

const isEmail = createValidator(
  (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
  "有効なメールアドレスを入力してください"
);

// バリデータを合成
const composeValidators = (...validators) => (value) => {
  for (const validate of validators) {
    const result = validate(value);
    if (!result.valid) {
      return result;
    }
  }
  return { valid: true };
};

// 使用例
const validateEmail = composeValidators(
  isRequired,
  minLength(5),
  isEmail
);

console.log(validateEmail("")); // { valid: false, error: "必須項目です" }
console.log(validateEmail("abc")); // { valid: false, error: "5文字以上で入力してください" }
console.log(validateEmail("invalid")); // { valid: false, error: "有効なメールアドレスを入力してください" }
console.log(validateEmail("test@example.com")); // { valid: true }

パターン3: イベントハンドラの抽象化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// イベントハンドラを関数型で抽象化
const preventDefault = (fn) => (event) => {
  event.preventDefault();
  return fn(event);
};

const stopPropagation = (fn) => (event) => {
  event.stopPropagation();
  return fn(event);
};

const getTargetValue = (fn) => (event) => fn(event.target.value);

const debounce = (delay) => (fn) => {
  let timeoutId;
  return (...args) => {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
};

// 使用例
const handleSearch = pipe(
  getTargetValue,
  debounce(300),
)((query) => {
  console.log(`検索: ${query}`);
});

// フォーム送信ハンドラ
const handleSubmit = preventDefault((event) => {
  const formData = new FormData(event.target);
  console.log("送信データ:", Object.fromEntries(formData));
});

関数型プログラミングを学ぶ際の注意点

過度な抽象化を避ける

関数型プログラミングのパターンは強力ですが、過度に使用するとかえって可読性が下がることがあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 過度に抽象化された例(読みにくい)
const result = pipe(
  filter(propEq("status", "active")),
  map(pick(["id", "name"])),
  sortBy(prop("name")),
  take(5)
)(users);

// 適度な抽象化(読みやすい)
const activeUsers = users.filter((user) => user.status === "active");
const formatted = activeUsers.map(({ id, name }) => ({ id, name }));
const sorted = [...formatted].sort((a, b) => a.name.localeCompare(b.name));
const topFive = sorted.slice(0, 5);

パフォーマンスへの配慮

メソッドチェーンは可読性を高めますが、大量のデータを処理する場合は中間配列が生成されるため、パフォーマンスに影響することがあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 中間配列が3つ生成される
const result = largeArray
  .filter((item) => item.active) // 中間配列1
  .map((item) => item.value) // 中間配列2
  .filter((value) => value > 100); // 中間配列3

// reduceで1回のループにまとめる
const resultOptimized = largeArray.reduce((acc, item) => {
  if (item.active && item.value > 100) {
    acc.push(item.value);
  }
  return acc;
}, []);

チームでの導入

関数型プログラミングのパターンをチームで導入する場合は、段階的に進めることをおすすめします。

  1. まずmapfilterreduceの基本をマスターする
  2. イミュータブルな操作を意識してコードを書く
  3. 純粋関数を意識して副作用を局所化する
  4. 必要に応じて関数合成やカリー化を導入する

まとめ

本記事では、JavaScriptにおける関数型プログラミングの基礎を解説しました。

  • イミュータブル: データを変更せず、新しいデータを作成することで予期しないバグを防ぐ
  • 純粋関数: 同じ入力に対して同じ出力を返し、副作用を持たない関数を書く
  • 高階関数: mapfilterreduceを活用して宣言的なコードを書く
  • 関数合成: 小さな関数を組み合わせて複雑な処理を構築する
  • カリー化: 関数の再利用性を高め、部分適用を可能にする

関数型プログラミングは、すべてを一度にマスターする必要はありません。まずはイミュータブルな操作と純粋関数を意識することから始め、徐々にスキルを広げていくことをおすすめします。

これらの概念を実践することで、より堅牢でテストしやすく、保守性の高いJavaScriptコードを書けるようになるでしょう。

参考リンク