Skip to content

Part II: 関数型スタイルのプログラミング

本章では、関数型プログラミングの核心となるテクニックを学びます。イミュータブルなデータ操作、高階関数、そして chain(flatMap)による複雑なデータ変換を習得します。


第3章: イミュータブルなデータ操作

3.1 イミュータブルとは

イミュータブル(不変)とは、一度作成されたデータが変更されないことを意味します。データを「変更」する代わりに、新しいデータを「作成」します。

uml diagram

TypeScript では readonly 修飾子と fp-ts の ReadonlyArray モジュールを使ってイミュータブルなデータを扱います。

3.2 配列の基本操作

ソースファイル: app/typescript/src/ch03_immutable_data.ts

appended - 要素の追加

// スプレッド演算子を使ったイミュータブルな追加
const appended = <T>(lst: readonly T[], element: T): readonly T[] =>
  [...lst, element]

const appleBook = ['Apple', 'Book'] as const
const appleBookMango = appended(appleBook, 'Mango')

// appleBook.length === 2       // 元のリストは変わらない
// appleBookMango.length === 3  // 新しいリストが作成される
// appleBookMango === ['Apple', 'Book', 'Mango']

slice - リストの切り出し

const firstTwo = <T>(lst: readonly T[]): readonly T[] =>
  lst.slice(0, 2)

const lastTwo = <T>(lst: readonly T[]): readonly T[] =>
  lst.slice(Math.max(0, lst.length - 2))

// firstTwo(['a', 'b', 'c']) === ['a', 'b']
// lastTwo(['a', 'b', 'c']) === ['b', 'c']

uml diagram

3.3 リストの変換例

ソースファイル: app/typescript/src/ch03_immutable_data.ts

// 最初の2要素を末尾に移動
const moveFirstTwoToEnd = <T>(lst: readonly T[]): readonly T[] => {
  const firstTwo = lst.slice(0, 2)
  const withoutFirstTwo = lst.slice(2)
  return [...withoutFirstTwo, ...firstTwo]
}

// moveFirstTwoToEnd(['a', 'b', 'c']) === ['c', 'a', 'b']

// 最後の要素の前に挿入
const insertBeforeLast = <T>(lst: readonly T[], element: T): readonly T[] => {
  if (lst.length === 0) return [element]
  const last = lst.slice(lst.length - 1)
  const withoutLast = lst.slice(0, lst.length - 1)
  return [...withoutLast, element, ...last]
}

// insertBeforeLast(['a', 'b'], 'c') === ['a', 'c', 'b']

3.4 旅程の再計画

ソースファイル: app/typescript/src/ch03_immutable_data.ts

旅行の計画変更をイミュータブルに行う例です。

const replan = (
  plan: readonly string[],
  newCity: string,
  beforeCity: string
): readonly string[] => {
  const index = plan.indexOf(beforeCity)
  if (index === -1) return [...plan, newCity]
  return [...plan.slice(0, index), newCity, ...plan.slice(index)]
}

const planA = ['Paris', 'Berlin', 'Kraków']
const planB = replan(planA, 'Vienna', 'Kraków')

// planB === ['Paris', 'Berlin', 'Vienna', 'Kraków']
// planA === ['Paris', 'Berlin', 'Kraków']  // 元の計画は変わらない!

uml diagram

3.5 イミュータブルなオブジェクト

TypeScript では readonly 修飾子を使ってイミュータブルな型を定義します。

interface Point {
  readonly x: number
  readonly y: number
}

const createPoint = (x: number, y: number): Point => ({ x, y })

// スプレッド演算子でコピーしながら更新
const withX = (point: Point, newX: number): Point => ({ ...point, x: newX })
const withY = (point: Point, newY: number): Point => ({ ...point, y: newY })

const p1 = createPoint(1, 2)
const p2 = withX(p1, 10)

// p1.x === 1   // 元のPointは変わらない
// p2.x === 10
// p2.y === 2

3.6 fp-ts を使ったイミュータブル操作

fp-ts の ReadonlyArray モジュールを使うと、より型安全にイミュータブル操作ができます。

import { pipe } from 'fp-ts/function'
import * as RA from 'fp-ts/ReadonlyArray'

// append - 要素を追加
const appendWithFpts = <T>(lst: readonly T[], element: T): readonly T[] =>
  pipe(lst, RA.append(element))

// prepend - 先頭に追加
const prependWithFpts = <T>(lst: readonly T[], element: T): readonly T[] =>
  pipe(lst, RA.prepend(element))

// concat - 連結
const concatWithFpts = <T>(
  lst1: readonly T[],
  lst2: readonly T[]
): readonly T[] =>
  pipe(lst1, RA.concat(lst2))

第4章: 関数を値として扱う

4.1 高階関数とは

高階関数(Higher-Order Function)とは、以下のいずれかを満たす関数です:

  1. 関数を引数として受け取る
  2. 関数を戻り値として返す

uml diagram

4.2 関数を引数として渡す

ソースファイル: app/typescript/src/ch04_higher_order.ts

sortBy - ソート基準を関数で指定

fp-ts の Ord を使って型安全にソートできます。

import { pipe } from 'fp-ts/function'
import * as RA from 'fp-ts/ReadonlyArray'
import * as Ord from 'fp-ts/Ord'
import * as N from 'fp-ts/number'

const score = (word: string): number => word.replaceAll('a', '').length

// 任意のスコア関数でソート(降順)
const rankedWords = (
  words: readonly string[],
  wordScore: (word: string) => number
): readonly string[] =>
  pipe(
    words,
    RA.sortBy([Ord.reverse(Ord.contramap(wordScore)(N.Ord))])
  )

const words = ['rust', 'java']
const sortedWords = rankedWords(words, score)
// sortedWords === ['rust', 'java']
// rust: 4文字 (r, u, s, t), java: 2文字 (j, v)

uml diagram

map - 各要素を変換

const getLengths = (words: readonly string[]): readonly number[] =>
  pipe(
    words,
    RA.map((w) => w.length)
  )

// getLengths(['scala', 'rust', 'ada']) === [5, 4, 3]

const doubleAll = (numbers: readonly number[]): readonly number[] =>
  pipe(
    numbers,
    RA.map((n) => n * 2)
  )

// doubleAll([5, 1, 2, 4, 0]) === [10, 2, 4, 8, 0]

uml diagram

filter - 条件に合う要素を抽出

const filterOdd = (numbers: readonly number[]): readonly number[] =>
  pipe(
    numbers,
    RA.filter((n) => n % 2 === 1)
  )

// filterOdd([5, 1, 2, 4, 0]) === [5, 1]

const filterLargerThan = (
  numbers: readonly number[],
  threshold: number
): readonly number[] =>
  pipe(
    numbers,
    RA.filter((n) => n > threshold)
  )

// filterLargerThan([5, 1, 2, 4, 0], 4) === [5]

reduce - 畳み込み

const sumAll = (numbers: readonly number[]): number =>
  pipe(
    numbers,
    RA.reduce(0, (acc, n) => acc + n)
  )

// sumAll([5, 1, 2, 4, 100]) === 112

const findMax = (numbers: readonly number[]): number | undefined =>
  pipe(
    numbers,
    RA.reduce(undefined as number | undefined, (max, n) =>
      max === undefined || n > max ? n : max
    )
  )

// findMax([5, 1, 2, 4, 15]) === 15

uml diagram

4.3 型とインターフェース

ソースファイル: app/typescript/src/ch04_higher_order.ts

interface ProgrammingLanguage {
  readonly name: string
  readonly year: number
}

const createLanguage = (name: string, year: number): ProgrammingLanguage =>
  ({ name, year })

const java = createLanguage('Java', 1995)
const scala = createLanguage('Scala', 2004)
const languages = [java, scala]

// フィールドにアクセス
const getNames = (langs: readonly ProgrammingLanguage[]): readonly string[] =>
  pipe(
    langs,
    RA.map((lang) => lang.name)
  )

// getNames(languages) === ['Java', 'Scala']

// 条件でフィルタ
const filterYoungLanguages = (
  langs: readonly ProgrammingLanguage[],
  afterYear: number
): readonly ProgrammingLanguage[] =>
  pipe(
    langs,
    RA.filter((lang) => lang.year > afterYear)
  )

// filterYoungLanguages(languages, 2000) === [scala]

4.4 関数を返す関数

ソースファイル: app/typescript/src/ch04_higher_order.ts

const largerThan = (n: number): ((i: number) => boolean) => (i) => i > n

// 使用例
pipe([5, 1, 2, 4, 0], RA.filter(largerThan(4)))
// => [5]

pipe([5, 1, 2, 4, 0], RA.filter(largerThan(1)))
// => [5, 2, 4]

uml diagram

4.5 カリー化

カリー化(Currying)は、複数の引数を取る関数を、引数を1つずつ取る関数のチェーンに変換する技法です。

// 通常の関数
const largerThanNormal = (n: number, i: number): boolean => i > n

// カリー化された関数
const largerThan = (n: number) => (i: number): boolean => i > n

// 使用例
const isLargerThan4 = largerThan(4)  // 部分適用
isLargerThan4(5)  // => true
isLargerThan4(3)  // => false

// filter に直接渡す
pipe([5, 1, 2, 4, 0], RA.filter(largerThan(4)))
// => [5]

uml diagram

4.6 ワードスコアリングの例

ソースファイル: app/typescript/src/ch04_higher_order.ts

複数のスコアリングロジックを組み合わせる例です。

const score = (word: string): number => word.replaceAll('a', '').length
const bonus = (word: string): number => (word.includes('c') ? 5 : 0)
const penalty = (word: string): number => (word.includes('s') ? 7 : 0)

// カリー化された高スコア単語フィルタ
const highScoringWordsWithThreshold =
  (wordScoreFn: (word: string) => number) =>
  (higherThan: number) =>
  (words: readonly string[]): readonly string[] =>
    pipe(
      words,
      RA.filter((word) => wordScoreFn(word) > higherThan)
    )

const words = ['ada', 'haskell', 'scala', 'java', 'rust']
const scorer = highScoringWordsWithThreshold(wordScoreWithBonusAndPenalty)

scorer(1)(words)
// => ['java']

scorer(0)(words)
// => ['ada', 'scala', 'java']

第5章: chain(flatMap)とネスト構造

5.1 flatten と flatMap

ソースファイル: app/typescript/src/ch05_flatmap.ts

flatten - ネストしたリストを平坦化

import { pipe } from 'fp-ts/function'
import * as RA from 'fp-ts/ReadonlyArray'

interface Book {
  readonly title: string
  readonly authors: readonly string[]
}

const books: readonly Book[] = [
  { title: 'FP in Scala', authors: ['Chiusano', 'Bjarnason'] },
  { title: 'The Hobbit', authors: ['Tolkien'] },
]

// map だけだとネストする
const authorLists = pipe(books, RA.map((book) => book.authors))
// [['Chiusano', 'Bjarnason'], ['Tolkien']]

// flatten で平坦化
const authors = pipe(authorLists, RA.flatten)
// ['Chiusano', 'Bjarnason', 'Tolkien']

uml diagram

chain = map + flatten

fp-ts では flatMapchain という名前で提供されています。

// map して flatten
const authors1 = pipe(
  books,
  RA.map((book) => book.authors),
  RA.flatten
)

// chain で同じことを1行で
const authors2 = pipe(
  books,
  RA.chain((book) => book.authors)
)

// authors1 === authors2
// ['Chiusano', 'Bjarnason', 'Tolkien']

5.2 chain によるリストサイズの変化

// 要素数が増える
pipe([1, 2, 3], RA.chain((i) => [i, i + 10]))
// [1, 11, 2, 12, 3, 13] - 6要素

// 要素数が同じ
pipe([1, 2, 3], RA.chain((i) => [i * 2]))
// [2, 4, 6] - 3要素

// 要素数が減る(フィルタリング効果)
pipe([1, 2, 3], RA.chain((i) => (i % 2 === 0 ? [i] : [])))
// [2] - 1要素

uml diagram

5.3 ネストした chain

複数のリストを組み合わせる場合、chain をネストします。

interface Book {
  readonly title: string
  readonly authors: readonly string[]
}

interface Movie {
  readonly title: string
}

const bookAdaptations = (author: string): readonly Movie[] => {
  if (author === 'Tolkien') {
    return [
      { title: 'An Unexpected Journey' },
      { title: 'The Desolation of Smaug' },
    ]
  }
  return []
}

const books: readonly Book[] = [
  { title: 'FP in Scala', authors: ['Chiusano', 'Bjarnason'] },
  { title: 'The Hobbit', authors: ['Tolkien'] },
]

// ネストした chain
const recommendations = pipe(
  books,
  RA.chain((book) =>
    pipe(
      book.authors,
      RA.chain((author) =>
        pipe(
          bookAdaptations(author),
          RA.map(
            (movie) =>
              `You may like ${movie.title}, because you liked ${author}'s ${book.title}`
          )
        )
      )
    )
  )
)

// 結果:
// [
//   "You may like An Unexpected Journey, because you liked Tolkien's The Hobbit",
//   "You may like The Desolation of Smaug, because you liked Tolkien's The Hobbit"
// ]

uml diagram

5.4 Point の生成例

ソースファイル: app/typescript/src/ch05_flatmap.ts

interface Point {
  readonly x: number
  readonly y: number
}

const createPoint = (x: number, y: number): Point => ({ x, y })

// 全ての (x, y) の組み合わせから Point を生成
const generatePoints = (
  xs: readonly number[],
  ys: readonly number[]
): readonly Point[] =>
  pipe(
    xs,
    RA.chain((x) =>
      pipe(
        ys,
        RA.map((y) => createPoint(x, y))
      )
    )
  )

generatePoints([1], [-2, 7])
// [{ x: 1, y: -2 }, { x: 1, y: 7 }]

generatePoints([1, 2], [-2, 7])
// [{ x: 1, y: -2 }, { x: 1, y: 7 }, { x: 2, y: -2 }, { x: 2, y: 7 }]

5.5 Option との組み合わせ

ソースファイル: app/typescript/src/ch05_flatmap.ts

fp-ts の Option 型と組み合わせることで、安全なデータ変換ができます。

import * as O from 'fp-ts/Option'

// Option のリストから Some の値だけを取り出す
const compactOptions = <T>(options: readonly O.Option<T>[]): readonly T[] =>
  pipe(options, RA.compact)

compactOptions([O.some(1), O.none, O.some(2), O.none, O.some(3)])
// [1, 2, 3]

// filterMap - map と filter を同時に
const filterMapExample = <T, U>(
  func: (item: T) => O.Option<U>,
  lst: readonly T[]
): readonly U[] =>
  pipe(lst, RA.filterMap(func))

filterMapExample(
  (s: string) => (s.length > 2 ? O.some(s.toUpperCase()) : O.none),
  ['a', 'abc', 'de', 'xyz']
)
// ['ABC', 'XYZ']

5.6 traverse - 効果の反転

ソースファイル: app/typescript/src/ch05_flatmap.ts

traverse は、リストの各要素に Option を返す関数を適用し、全てが Some なら Some<配列> を返し、一つでも None なら None を返します。

const traverseOption = <T, U>(
  func: (item: T) => O.Option<U>,
  lst: readonly T[]
): O.Option<readonly U[]> =>
  pipe(lst, RA.traverse(O.Applicative)(func))

const safeDivide = (n: number): O.Option<number> =>
  n === 0 ? O.none : O.some(10 / n)

traverseOption(safeDivide, [1, 2, 5])
// some([10, 5, 2])

traverseOption(safeDivide, [1, 0, 5])
// none - 0で割る要素があるため全体が失敗

5.7 円内の点の判定

ソースファイル: app/typescript/src/ch05_flatmap.ts

chain でフィルタリングも行う例です。

interface Point {
  readonly x: number
  readonly y: number
}

const points: readonly Point[] = [
  { x: 5, y: 2 },
  { x: 1, y: 1 },
]
const radiuses = [2, 1]

const isInside = (point: Point, radius: number): boolean =>
  radius * radius >= point.x * point.x + point.y * point.y

// 全組み合わせを生成
const allCombinations = (
  points: readonly Point[],
  radiuses: readonly number[]
): readonly string[] =>
  pipe(
    radiuses,
    RA.chain((r) =>
      pipe(
        points,
        RA.map(
          (point) =>
            `Point(${point.x},${point.y}) is within a radius of ${r}: ${isInside(point, r)}`
        )
      )
    )
  )

// 結果:
// [
//   "Point(5,2) is within a radius of 2: false",
//   "Point(1,1) is within a radius of 2: true",
//   "Point(5,2) is within a radius of 1: false",
//   "Point(1,1) is within a radius of 1: false"
// ]

chain によるフィルタリング

// 円内の点のみを抽出
const insidePoints = (
  points: readonly Point[],
  radiuses: readonly number[]
): readonly string[] =>
  pipe(
    radiuses,
    RA.chain((r) =>
      pipe(
        points,
        RA.filter((point) => isInside(point, r)),
        RA.map(
          (point) => `Point(${point.x},${point.y}) is within a radius of ${r}`
        )
      )
    )
  )

// 結果: ["Point(1,1) is within a radius of 2"]

uml diagram


まとめ

Part II で学んだこと

uml diagram

キーポイント

主要概念 キー操作
第3章 イミュータブル slice, スプレッド演算子, readonly
第4章 高階関数 RA.map, RA.filter, RA.reduce, RA.sortBy
第5章 平坦化 RA.flatten, RA.chain, RA.traverse

Scala vs TypeScript fp-ts 比較

Scala TypeScript (fp-ts) 説明
list.appended(x) [...list, x] 末尾に追加
list.slice(a, b) list.slice(a, b) スライス
list.map(f) pipe(list, RA.map(f)) map
list.filter(p) pipe(list, RA.filter(p)) filter
list.foldLeft(z)(f) pipe(list, RA.reduce(z, f)) reduce
list.flatMap(f) pipe(list, RA.chain(f)) flatMap
list.flatten pipe(list, RA.flatten) flatten
list.sortBy(f) pipe(list, RA.sortBy([ord])) sortBy

重要な法則

  1. イミュータブルデータ: 元のデータは変更せず、新しいデータを作成する
  2. 関数は値: 関数を引数として渡したり、戻り値として返したりできる
  3. chain パターン: ネストした構造を平坦化しながら変換する
  4. traverse パターン: 効果(Option等)を反転させてリスト全体を扱う

次のステップ

Part III では、以下のトピックを学びます:

  • Option 型による安全なエラーハンドリング
  • Either 型と複合的なエラー処理
  • エラー処理のパターン

演習問題

問題 1: イミュータブルな操作

以下の関数を実装してください。リストの中央に要素を挿入する関数です。

const insertAtMiddle = <T>(list: readonly T[], element: T): readonly T[] => ???

// 期待される動作
// insertAtMiddle(['a', 'b', 'c', 'd'], 'X') === ['a', 'b', 'X', 'c', 'd']
// insertAtMiddle(['a', 'b'], 'X') === ['a', 'X', 'b']
解答
const insertAtMiddle = <T>(list: readonly T[], element: T): readonly T[] => {
  const middle = Math.floor(list.length / 2)
  const before = list.slice(0, middle)
  const after = list.slice(middle)
  return [...before, element, ...after]
}

問題 2: 高階関数

以下の関数を実装してください。条件を満たす要素の数をカウントする関数です。

const countWhere = <T>(
  list: readonly T[],
  predicate: (item: T) => boolean
): number => ???

// 期待される動作
// countWhere([1, 2, 3, 4, 5], (i) => i > 3) === 2
// countWhere(['a', 'bb', 'ccc'], (s) => s.length > 1) === 2
解答
import { pipe } from 'fp-ts/function'
import * as RA from 'fp-ts/ReadonlyArray'

const countWhere = <T>(
  list: readonly T[],
  predicate: (item: T) => boolean
): number =>
  pipe(
    list,
    RA.filter(predicate),
    (filtered) => filtered.length
  )

// または reduce を使って
const countWhere2 = <T>(
  list: readonly T[],
  predicate: (item: T) => boolean
): number =>
  pipe(
    list,
    RA.reduce(0, (count, elem) => (predicate(elem) ? count + 1 : count))
  )

問題 3: chain の活用

以下のネストした配列を chain で平坦化してください。

const nested = [[1, 2], [10, 20], [100, 200]]
// それぞれの配列の各要素を足し合わせた全ての組み合わせを生成
// 期待される結果: [111, 211, 121, 221, 112, 212, 122, 222]
解答
import { pipe } from 'fp-ts/function'
import * as RA from 'fp-ts/ReadonlyArray'

const result = pipe(
  [1, 2],
  RA.chain((x) =>
    pipe(
      [10, 20],
      RA.chain((y) =>
        pipe(
          [100, 200],
          RA.map((z) => x + y + z)
        )
      )
    )
  )
)

// 結果: [111, 211, 121, 221, 112, 212, 122, 222]

問題 4: chain によるフィルタリング

filter を使わずに、chain だけで偶数のみを抽出するコードを書いてください。

const numbers = [1, 2, 3, 4, 5, 6]
// 偶数のみを抽出: [2, 4, 6]
解答
import { pipe } from 'fp-ts/function'
import * as RA from 'fp-ts/ReadonlyArray'

const evenNumbers = pipe(
  numbers,
  RA.chain((n) => (n % 2 === 0 ? [n] : []))
)

// または関数に分離
const evenFilter = (n: number): readonly number[] =>
  n % 2 === 0 ? [n] : []

const evenNumbers2 = pipe(numbers, RA.chain(evenFilter))