Function Composition - Building from Small Pieces について
Function Composition - Building from Small Piecesは、fp-tsを中心とした例で関数合成を解説するTypeScript開発者向けガイドです。読みやすいパイプライン、再利用可能なユーティリティ、データを最後の引数にする設計、合成が有効な場面と複雑さを増す場面を扱います。
主な機能
- 01pipeで変換を連結
- 02flowで再利用可能な関数を作成
- 03データを最後に取るユーティリティ
- 04traceとブレークポイントでパイプラインをデバッグ
- 05関数合成が適切かを判断
活用シーン
- 01APIレスポンスの段階的な変換
- 02設定可能なフォーマッターの作成
- 03入力バリデーターの合成
- 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 を追加する |
まとめ
関数合成とは、単純な部品から複雑な振る舞いを組み立てることです。
- pipe から始める — データに対する操作をつなぎ、上から下へ読み進めます
- 再利用可能なユーティリティを切り出す — 1つのことをうまくこなす小さな関数を作ります
- データラストを使う — 設定を先に、データを最後に置くことで合成しやすくなります
- やめどきを知る — すべてを合成する必要はありません
- trace でデバッグする — パイプラインを壊さずにログを挿入します
目標は、すべてを合成することではありません。コードをより明確で保守しやすくすることです。役立つときは合成し、そうでないときは使わないようにしましょう。
コメント
Markdown 対応