mcpField
F
Function Composition - Building from Small Pieces

Function Composition - Building from Small Pieces

github.com/whatiskadudoing開発1450 回インストール39 週間前に更新
リポジトリ

Function Composition - Building from Small Pieces について

Function Composition - Building from Small Piecesは、fp-tsを中心とした例で関数合成を解説するTypeScript開発者向けガイドです。読みやすいパイプライン、再利用可能なユーティリティ、データを最後の引数にする設計、合成が有効な場面と複雑さを増す場面を扱います。

主な機能

  1. 01pipeで変換を連結
  2. 02flowで再利用可能な関数を作成
  3. 03データを最後に取るユーティリティ
  4. 04traceとブレークポイントでパイプラインをデバッグ
  5. 05関数合成が適切かを判断

活用シーン

  1. 01APIレスポンスの段階的な変換
  2. 02設定可能なフォーマッターの作成
  3. 03入力バリデーターの合成
  4. 04パイプライン途中の値の確認とデバッグ

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/whatiskadudoing/fp-ts-skills --skill function-composition---building-from-small-pieces

インストール後、Claude Code で/function-composition---building-from-small-piecesを入力して読み込まれたか確認します。

詳細

関数合成 — 小さな部品から組み立てる

基本的な考え方はシンプルです。

pipe(data, fn1, fn2, fn3) === fn3(fn2(fn1(data)))

これだけです。圏論は必要ありません。関数を左から右へつなげるだけです。


1. pipe() — 新しい頼れる相棒

基本パターン
import { pipe } from 'fp-ts/function'

// Instead of nested calls:
const result = formatOutput(calculateTotal(validateInput(parseData(rawInput))))

// Write it as a pipeline:
const result = pipe(
  rawInput,
  parseData,
  validateInput,
  calculateTotal,
  formatOutput
)
これが重要な理由

変更前(入れ子になった呼び出し):

// Read from inside out, right to left - confusing
const userName = capitalize(trim(getProperty('name')(user)))

変更後(pipe):

// Read top to bottom, left to right - natural
const userName = pipe(
  user,
  getProperty('name'),
  trim,
  capitalize
)
実例:ユーザー入力の処理
import { pipe } from 'fp-ts/function'

interface UserInput {
  email: string
  name: string
  age: string
}

interface CleanUser {
  email: string
  name: string
  age: number
}

// Small, focused functions
const trimEmail = (input: UserInput): UserInput => ({
  ...input,
  email: input.email.trim().toLowerCase()
})

const trimName = (input: UserInput): UserInput => ({
  ...input,
  name: input.name.trim()
})

const parseAge = (input: UserInput): CleanUser => ({
  ...input,
  age: parseInt(input.age, 10) || 0
})

// Compose them
const cleanUserInput = (raw: UserInput): CleanUser =>
  pipe(raw, trimEmail, trimName, parseAge)

// Use it
cleanUserInput({ email: '  [email protected]  ', name: '  Alice  ', age: '30' })
// { email: '[email protected]', name: 'Alice', age: 30 }

2. 再利用可能なユーティリティを組み立てる

大切なのは、1つのことをうまくこなす小さな関数を作ることです。

文字列ユーティリティ
import { pipe, flow } from 'fp-ts/function'

// Basic building blocks
const trim = (s: string): string => s.trim()
const lowercase = (s: string): string => s.toLowerCase()
const uppercase = (s: string): string => s.toUpperCase()
const replace = (pattern: RegExp, replacement: string) =>
  (s: string): string => s.replace(pattern, replacement)
const prefix = (pre: string) => (s: string): string => `${pre}${s}`
const suffix = (suf: string) => (s: string): string => `${s}${suf}`

// Combine them into useful utilities
const slugify = flow(
  trim,
  lowercase,
  replace(/\s+/g, '-'),
  replace(/[^a-z0-9-]/g, '')
)

const titleCase = flow(
  trim,
  lowercase,
  replace(/\b\w/g, c => c.toUpperCase())
)

const kebabCase = flow(
  trim,
  replace(/([a-z])([A-Z])/g, '$1-$2'),
  lowercase,
  replace(/\s+/g, '-')
)

// Use them
slugify('  Hello World! ')     // 'hello-world'
titleCase('hello world')       // 'Hello World'
kebabCase('myVariableName')    // 'my-variable-name'
数値ユーティリティ
const clamp = (min: number, max: number) =>
  (n: number): number => Math.max(min, Math.min(max, n))

const round = (decimals: number) =>
  (n: number): number => Math.round(n * 10 ** decimals) / 10 ** decimals

const multiply = (factor: number) =>
  (n: number): number => n * factor

const add = (amount: number) =>
  (n: number): number => n + amount

// Combine for specific use cases
const toPercentage = flow(
  multiply(100),
  round(1),
  suffix('%')
)

const formatPrice = flow(
  round(2),
  n => n.toFixed(2),
  prefix('$')
)

const normalizeScore = flow(
  clamp(0, 100),
  round(0)
)

// Use them
toPercentage(0.8567)  // '85.7%'
formatPrice(19.999)   // '$20.00'
normalizeScore(105)   // 100
配列ユーティリティ
import * as A from 'fp-ts/Array'
import { pipe, flow } from 'fp-ts/function'

// Property accessors
const prop = <T, K extends keyof T>(key: K) =>
  (obj: T): T[K] => obj[key]

// Predicates
const isNotNull = <T>(value: T | null | undefined): value is T =>
  value != null

const hasLength = (min: number) =>
  (arr: readonly unknown[]): boolean => arr.length >= min

// Combining filters and maps
interface Product {
  id: string
  name: string
  price: number
  inStock: boolean
}

const getInStockProductNames = flow(
  A.filter((p: Product) => p.inStock),
  A.map(prop('name'))
)

const getTotalValue = flow(
  A.map((p: Product) => p.price),
  A.reduce(0, (acc, price) => acc + price)
)

const getProductsSortedByPrice = flow(
  A.sort<Product>((a, b) => a.price - b.price)
)

3. 柔軟性を高めるデータラスト

引数の順序が重要な理由
// Data-first: Hard to compose
const map1 = <A, B>(arr: A[], fn: (a: A) => B): B[] => arr.map(fn)

// Can't easily create reusable functions:
const doubleAll = (arr: number[]) => map1(arr, n => n * 2)  // Must wrap

// Data-last: Easy to compose
const map2 = <A, B>(fn: (a: A) => B) => (arr: A[]): B[] => arr.map(fn)

// Create reusable functions by partial application:
const doubleAll = map2((n: number) => n * 2)

// Works naturally in pipes:
pipe([1, 2, 3], doubleAll)  // [2, 4, 6]
パターン
// General rule: configuration first, data last

// Good: Configuration -> Data
const filter = <A>(predicate: (a: A) => boolean) =>
  (arr: A[]): A[] => arr.filter(predicate)

const map = <A, B>(fn: (a: A) => B) =>
  (arr: A[]): B[] => arr.map(fn)

const formatWith = (formatter: Intl.NumberFormat) =>
  (n: number): string => formatter.format(n)

// All work smoothly in pipes:
const processNumbers = flow(
  filter((n: number) => n > 0),
  map(n => n * 2),
  formatWith(new Intl.NumberFormat('en-US'))
)
データファーストの API を変換する
// Many built-in methods are data-first (method on object)
// Wrap them to be data-last

// Date formatting
const formatDate = (options: Intl.DateTimeFormatOptions) =>
  (locale: string) =>
    (date: Date): string => date.toLocaleDateString(locale, options)

const formatShortDate = formatDate({ month: 'short', day: 'numeric' })('en-US')

pipe(new Date(), formatShortDate)  // 'Jan 30'

// JSON operations
const parseJSON = <T>() =>
  (str: string): T => JSON.parse(str)

const stringifyJSON = (indent?: number) =>
  <T>(data: T): string => JSON.stringify(data, null, indent)

// Regular expressions
const match = (regex: RegExp) =>
  (str: string): RegExpMatchArray | null => str.match(regex)

const test = (regex: RegExp) =>
  (str: string): boolean => regex.test(str)

const split = (separator: string | RegExp) =>
  (str: string): string[] => str.split(separator)

4. 合成が役立つとき(役立たないとき)

合成が適しているケース

1. 複数ステップのデータ変換:

// Processing API responses
const processApiResponse = flow(
  extractData,
  normalizeFields,
  validateSchema,
  transformForUI
)

2. 特化した関数の作成:

// Currency formatters from a general formatter
const formatCurrency = (locale: string, currency: string) =>
  (amount: number): string =>
    new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)

const formatUSD = formatCurrency('en-US', 'USD')
const formatEUR = formatCurrency('de-DE', 'EUR')
const formatGBP = formatCurrency('en-GB', 'GBP')

3. 検証チェーン:

const validateEmail = flow(
  trim,
  lowercase,
  (email: string) => email.includes('@') ? email : null
)

const validateUsername = flow(
  trim,
  (name: string) => name.length >= 3 ? name : null
)

4. イベントハンドラー:

const handleFormSubmit = flow(
  preventDefault,
  extractFormData,
  validateForm,
  submitToAPI
)
合成しないほうがよいケース

1. 単純な関数のほうがわかりやすいとき:

// Overengineered:
const isAdult = flow(
  prop<Person, 'age'>('age'),
  gte(18)
)

// Just write it:
const isAdult = (person: Person): boolean => person.age >= 18

2. 複数の入力が必要なとき:

// Awkward with composition:
const calculateDiscount = (price: number, discountPercent: number): number =>
  pipe(
    price,
    multiply(1 - discountPercent / 100)
  )
// The discountPercent is awkwardly captured

// Just use a regular function:
const calculateDiscount = (price: number, discountPercent: number): number =>
  price * (1 - discountPercent / 100)

3. デバッグが難しくなるとき:

// If you can't figure out what's happening:
const mysteryPipeline = flow(fn1, fn2, fn3, fn4, fn5, fn6, fn7)

// Break it up and name the stages:
const parse = flow(fn1, fn2)
const validate = flow(fn3, fn4)
const transform = flow(fn5, fn6, fn7)

// Or just use a regular function with intermediate variables:
const processData = (input: Input): Output => {
  const parsed = parse(input)
  const validated = validate(parsed)
  const transformed = transform(validated)
  return transformed
}

4. チームがこのパターンに慣れていないとき:

// If your team isn't familiar with FP:
const result = pipe(
  data,
  A.filter(isActive),
  A.map(getName),
  A.sort(ordString.compare)
)

// Consider the more familiar version:
const result = data
  .filter(isActive)
  .map(getName)
  .sort((a, b) => a.localeCompare(b))
判断ガイド
状況合成を使う?
複数ステップの変換はい
再利用可能なユーティリティの作成はい
単一の操作いいえ
関連のない複数の入力いいえ
複雑な分岐ロジック使わないほうがよい場合もあります
チームが関数型プログラミングに慣れていないシンプルに始める

5. パイプラインのデバッグ

trace 関数
// Simple debug helper
const trace = <A>(label: string) =>
  (value: A): A => {
    console.log(`${label}:`, value)
    return value
  }

// Use it in pipelines
const result = pipe(
  input,
  trace('input'),
  step1,
  trace('after step1'),
  step2,
  trace('after step2'),
  step3,
  trace('final')
)
条件付きトレース
// Only trace in development
const traceIf = (enabled: boolean) =>
  <A>(label: string) =>
    (value: A): A => {
      if (enabled) console.log(`${label}:`, value)
      return value
    }

const debug = traceIf(process.env.NODE_ENV === 'development')

pipe(
  data,
  debug('input'),
  transform,
  debug('output')
)
構造化ログ
// More sophisticated tracing
interface TraceOptions {
  label: string
  transform?: (value: unknown) => unknown
  condition?: (value: unknown) => boolean
}

const traceWith = (options: TraceOptions) =>
  <A>(value: A): A => {
    if (!options.condition || options.condition(value)) {
      const output = options.transform ? options.transform(value) : value
      console.log(`[${options.label}]`, output)
    }
    return value
  }

// Use it
pipe(
  users,
  traceWith({ label: 'users', transform: arr => `count: ${arr.length}` }),
  A.filter(isActive),
  traceWith({ label: 'active', transform: arr => `count: ${arr.length}` })
)
ブレークポイントによるデバッグ
// Insert a breakpoint
const breakpoint = <A>(value: A): A => {
  debugger  // Execution pauses here
  return value
}

pipe(
  data,
  step1,
  breakpoint,  // Inspect value here
  step2
)
パイプライン途中での型チェック
// Verify types are what you expect
const assertType = <Expected>() =>
  <Actual extends Expected>(value: Actual): Actual => value

pipe(
  data,
  parseInput,
  assertType<{ name: string; age: number }>(),  // TypeScript error if wrong
  formatOutput
)

実践的なパターン

パターン 1:データ処理パイプライン
import { pipe, flow } from 'fp-ts/function'
import * as A from 'fp-ts/Array'
import * as O from 'fp-ts/Option'

interface RawRecord {
  id: string
  timestamp: string
  value: string
  status: string
}

interface ProcessedRecord {
  id: string
  date: Date
  value: number
  isActive: boolean
}

// Individual processing steps
const parseTimestamp = (r: RawRecord) => ({
  ...r,
  date: new Date(r.timestamp)
})

const parseValue = (r: { id: string; date: Date; value: string; status: string }) => ({
  id: r.id,
  date: r.date,
  value: parseFloat(r.value) || 0,
  isActive: r.status === 'active'
})

const isValid = (r: ProcessedRecord): boolean =>
  !isNaN(r.date.getTime()) && r.value >= 0

const sortByDate = A.sort<ProcessedRecord>((a, b) =>
  a.date.getTime() - b.date.getTime()
)

// Compose into a pipeline
const processRecords = flow(
  A.map(parseTimestamp),
  A.map(parseValue),
  A.filter(isValid),
  sortByDate
)

// Use it
const result = processRecords(rawData)
パターン 2:特化した関数の作成
// General HTTP client
interface RequestConfig {
  baseUrl: string
  headers: Record<string, string>
}

const createFetcher = (config: RequestConfig) =>
  (endpoint: string) =>
    <T>(): Promise<T> =>
      fetch(`${config.baseUrl}${endpoint}`, { headers: config.headers })
        .then(r => r.json())

// Create specialized fetchers
const apiConfig = {
  baseUrl: 'https://api.example.com',
  headers: { 'Authorization': 'Bearer token123' }
}

const apiFetch = createFetcher(apiConfig)

// Even more specialized
const fetchUsers = apiFetch('/users')<User[]>
const fetchProducts = apiFetch('/products')<Product[]>
const fetchOrders = apiFetch('/orders')<Order[]>
パターン 3:バリデーターの合成
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'

type ValidationError = string
type Validator<T> = (value: T) => E.Either<ValidationError, T>

// Basic validators
const nonEmpty: Validator<string> = (s) =>
  s.length > 0
    ? E.right(s)
    : E.left('Value cannot be empty')

const minLength = (min: number): Validator<string> => (s) =>
  s.length >= min
    ? E.right(s)
    : E.left(`Must be at least ${min} characters`)

const maxLength = (max: number): Validator<string> => (s) =>
  s.length <= max
    ? E.right(s)
    : E.left(`Must be at most ${max} characters`)

const matches = (pattern: RegExp, message: string): Validator<string> => (s) =>
  pattern.test(s)
    ? E.right(s)
    : E.left(message)

// Compose validators
const validateUsername = (input: string): E.Either<ValidationError, string> =>
  pipe(
    E.right(input),
    E.flatMap(nonEmpty),
    E.flatMap(minLength(3)),
    E.flatMap(maxLength(20)),
    E.flatMap(matches(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'))
  )

const validateEmail = (input: string): E.Either<ValidationError, string> =>
  pipe(
    E.right(input),
    E.flatMap(nonEmpty),
    E.flatMap(matches(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format'))
  )
パターン 4:API 変換の連結
import * as TE from 'fp-ts/TaskEither'
import { pipe } from 'fp-ts/function'

interface ApiUser { id: number; name: string; email: string }
interface ApiPosts { userId: number; title: string; body: string }[]
interface UserWithPosts { user: ApiUser; posts: ApiPosts; postCount: number }

// API calls as TaskEither
const fetchUser = (id: number): TE.TaskEither<Error, ApiUser> =>
  TE.tryCatch(
    () => fetch(`/api/users/${id}`).then(r => r.json()),
    (e) => new Error(String(e))
  )

const fetchUserPosts = (userId: number): TE.TaskEither<Error, ApiPosts> =>
  TE.tryCatch(
    () => fetch(`/api/users/${userId}/posts`).then(r => r.json()),
    (e) => new Error(String(e))
  )

// Combine into a pipeline
const getUserWithPosts = (userId: number): TE.TaskEither<Error, UserWithPosts> =>
  pipe(
    fetchUser(userId),
    TE.flatMap(user =>
      pipe(
        fetchUserPosts(user.id),
        TE.map(posts => ({
          user,
          posts,
          postCount: posts.length
        }))
      )
    )
  )

// Usage
const program = pipe(
  getUserWithPosts(123),
  TE.map(data => console.log(`${data.user.name} has ${data.postCount} posts`)),
  TE.mapLeft(error => console.error('Failed:', error.message))
)

program()  // Execute the async operation
パターン 5:設定可能なユーティリティ
// Configuration-driven formatting
interface FormatConfig {
  locale: string
  currency: string
  dateFormat: Intl.DateTimeFormatOptions
  numberFormat: Intl.NumberFormatOptions
}

const createFormatter = (config: FormatConfig) => ({
  currency: (amount: number): string =>
    new Intl.NumberFormat(config.locale, {
      style: 'currency',
      currency: config.currency
    }).format(amount),

  date: (date: Date): string =>
    date.toLocaleDateString(config.locale, config.dateFormat),

  number: (n: number): string =>
    new Intl.NumberFormat(config.locale, config.numberFormat).format(n),

  percent: (n: number): string =>
    new Intl.NumberFormat(config.locale, { style: 'percent' }).format(n)
})

// Create region-specific formatters
const usFormatter = createFormatter({
  locale: 'en-US',
  currency: 'USD',
  dateFormat: { month: 'short', day: 'numeric', year: 'numeric' },
  numberFormat: { maximumFractionDigits: 2 }
})

const euFormatter = createFormatter({
  locale: 'de-DE',
  currency: 'EUR',
  dateFormat: { day: '2-digit', month: '2-digit', year: 'numeric' },
  numberFormat: { maximumFractionDigits: 2 }
})

// Use them
usFormatter.currency(1234.56)  // '$1,234.56'
euFormatter.currency(1234.56)  // '1.234,56 EUR'

クイックリファレンス

pipe と flow の違い
// pipe: Start with a value, transform immediately
const result = pipe(value, fn1, fn2, fn3)

// flow: Create a reusable function
const transform = flow(fn1, fn2, fn3)
const result = transform(value)
合成可能な関数の作成
// Data-last for pipes
const filter = <A>(pred: (a: A) => boolean) => (arr: A[]): A[] => arr.filter(pred)
const map = <A, B>(fn: (a: A) => B) => (arr: A[]): B[] => arr.map(fn)

// Configuration first, data last
const format = (options: Options) => (value: Value): string => ...
デバッグ用ヘルパー
const trace = <A>(label: string) => (a: A): A => { console.log(label, a); return a }
const breakpoint = <A>(a: A): A => { debugger; return a }
使用する場面
シナリオ方法
ステップを通じてデータを変換するpipe(data, step1, step2, ...)
再利用可能な変換を作成するflow(step1, step2, ...)
単純な単一操作通常の関数
関連のない複数の入力通常の関数
チームが関数型プログラミングを学んでいるpipe から始め、後で flow を追加する

まとめ

関数合成とは、単純な部品から複雑な振る舞いを組み立てることです。

  1. pipe から始める — データに対する操作をつなぎ、上から下へ読み進めます
  2. 再利用可能なユーティリティを切り出す — 1つのことをうまくこなす小さな関数を作ります
  3. データラストを使う — 設定を先に、データを最後に置くことで合成しやすくなります
  4. やめどきを知る — すべてを合成する必要はありません
  5. trace でデバッグする — パイプラインを壊さずにログを挿入します

目標は、すべてを合成することではありません。コードをより明確で保守しやすくすることです。役立つときは合成し、そうでないときは使わないようにしましょう。

コメント