Pragmatic Functional Programming について
関数型プログラミングを適切な場面で取り入れる開発者向けのガイドです。fp-ts の pipe、Option、Either、map、flatMap を例に、読みやすさを優先し、関数型のパターンを避ける場面も説明します。
主な機能
- 01pipe による変換処理の連結
- 02Option による欠損値の処理
- 03Either によるエラーの明示
- 04map と flatMap による変換と連鎖
- 05読みやすさを基準にした採用判断
活用シーン
- 01複数段階のデータ変換の整理
- 02オプショナル値と既定値の表現
- 03想定されるエラーの確認可能な返却
- 04失敗する可能性のある処理の連鎖
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ whatiskadudoing/ fp-ts-skills --skill pragmatic-functional-programmingインストール後、Claude Code で/pragmatic-functional-programmingを入力して読み込まれたか確認します。
詳細
実践的な関数型プログラミング
まずはこれを読んでください。 このガイドでは、学術的な専門用語を避け、本当に重要なことを紹介します。圏論も、抽象的な理論もありません。コードをより良くするパターンだけを扱います。
黄金律
関数型プログラミングによってコードが読みにくくなるなら、使わないでください。
FP はツールであって、宗教ではありません。役立つときに使い、そうでないときは使わないでください。
FP の 80/20
次の 5 つのパターンで、メリットの大部分を得られます。ほかのことを探求する前に、まずこれらを習得しましょう。
1. Pipe:処理を明確につなぐ
関数呼び出しを入れ子にしたり、中間変数を作成したりする代わりに、読み進める順に処理をつなぎます。
import { pipe } from 'fp-ts/function'
// Before: Hard to read (inside-out)
const result = format(validate(parse(input)))
// Before: Too many variables
const parsed = parse(input)
const validated = validate(parsed)
const result = format(validated)
// After: Clear, linear flow
const result = pipe(
input,
parse,
validate,
format
)
pipe を使うとき:
- 同じデータに対して 3 つ以上の変換を行う場合
- 使い捨ての変数に名前を付けていることに気づいた場合
- ロジックを上から下へ読むほうが分かりやすい場合
pipe を使わないとき:
- 処理が 1~2 個だけの場合(直接呼び出せば十分です)
- 処理同士が自然につながらない場合
2. Option:null チェックなしで値の欠落に対処する
あちこちに if (x !== null && x !== undefined) と書くのはやめましょう。
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'
// Before: Defensive null checking
function getUserCity(user: User | null): string {
if (user === null) return 'Unknown'
if (user.address === null) return 'Unknown'
if (user.address.city === null) return 'Unknown'
return user.address.city
}
// After: Chain through potential missing values
const getUserCity = (user: User | null): string =>
pipe(
O.fromNullable(user),
O.flatMap(u => O.fromNullable(u.address)),
O.flatMap(a => O.fromNullable(a.city)),
O.getOrElse(() => 'Unknown')
)
平易な言葉で言うと:
O.fromNullable(x)= 「この値をラップし、null/undefined を『何もない』として扱う」O.flatMap(fn)= 「値があれば、この関数を適用する」O.getOrElse(() => default)= 「中身を取り出す。何もなければ、このデフォルト値を使う」
3. Either:エラーを明示する
予期される失敗に対して例外をスローするのはやめましょう。エラーを値として返します。
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
// Before: Hidden failure mode
function parseAge(input: string): number {
const age = parseInt(input, 10)
if (isNaN(age)) throw new Error('Invalid age')
if (age < 0) throw new Error('Age cannot be negative')
return age
}
// After: Errors are visible in the type
function parseAge(input: string): E.Either<string, number> {
const age = parseInt(input, 10)
if (isNaN(age)) return E.left('Invalid age')
if (age < 0) return E.left('Age cannot be negative')
return E.right(age)
}
// Using it
const result = parseAge(userInput)
if (E.isRight(result)) {
console.log(`Age is ${result.right}`)
} else {
console.log(`Error: ${result.left}`)
}
平易な言葉で言うと:
E.right(value)= 「この値で成功」E.left(error)= 「このエラーで失敗」E.isRight(x)= 「成功したか?」
4. Map:取り出さずに変換する
コンテナーから値を取り出さずに、その中の値を変換します。
import * as O from 'fp-ts/Option'
import * as E from 'fp-ts/Either'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'
// Transform inside Option
const maybeUser: O.Option<User> = O.some({ name: 'Alice', age: 30 })
const maybeName: O.Option<string> = pipe(
maybeUser,
O.map(user => user.name)
)
// Transform inside Either
const result: E.Either<Error, number> = E.right(5)
const doubled: E.Either<Error, number> = pipe(
result,
E.map(n => n * 2)
)
// Transform arrays (same concept!)
const numbers = [1, 2, 3]
const doubled = pipe(
numbers,
A.map(n => n * 2)
)
5. FlatMap:失敗する可能性のある処理をつなぐ
各ステップで失敗する可能性がある場合、それらをつなぎます。
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
const parseJSON = (s: string): E.Either<string, unknown> =>
E.tryCatch(() => JSON.parse(s), () => 'Invalid JSON')
const extractEmail = (data: unknown): E.Either<string, string> => {
if (typeof data === 'object' && data !== null && 'email' in data) {
return E.right((data as { email: string }).email)
}
return E.left('No email field')
}
const validateEmail = (email: string): E.Either<string, string> =>
email.includes('@') ? E.right(email) : E.left('Invalid email format')
// Chain all steps - if any fails, the whole thing fails
const getValidEmail = (input: string): E.Either<string, string> =>
pipe(
parseJSON(input),
E.flatMap(extractEmail),
E.flatMap(validateEmail)
)
// Success path: Right('[email protected]')
// Any failure: Left('specific error message')
平易な言葉で言うと: flatMap は「これが成功したら、次のことを試す」という意味です。
FP を使わないとき
関数型プログラミングが常に正解とは限りません。シンプルにしておくべき場合を紹介します。
単純な null チェック
// Just use optional chaining - it's built into the language
const city = user?.address?.city ?? 'Unknown'
// DON'T overcomplicate it
const city = pipe(
O.fromNullable(user),
O.flatMap(u => O.fromNullable(u.address)),
O.flatMap(a => O.fromNullable(a.city)),
O.getOrElse(() => 'Unknown')
)
単純なループ
// A for loop is fine when you need early exit or complex logic
function findFirst(items: Item[], predicate: (i: Item) => boolean): Item | null {
for (const item of items) {
if (predicate(item)) return item
}
return null
}
// DON'T force FP when it doesn't help
const result = pipe(
items,
A.findFirst(predicate),
O.toNullable
)
パフォーマンスが重要なコード
// For hot paths, imperative is faster (no intermediate arrays)
function sumLarge(numbers: number[]): number {
let sum = 0
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i]
}
return sum
}
// fp-ts creates intermediate structures
const sum = pipe(numbers, A.reduce(0, (acc, n) => acc + n))
チームが FP を知らない場合
コードを読めるのがあなただけなら、それは良いコードではありません。
// If your team knows this pattern
async function getUser(id: string): Promise<User | null> {
try {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) return null
return await response.json()
} catch {
return null
}
}
// Don't force this on them
const getUser = (id: string): TE.TaskEither<Error, User> =>
pipe(
TE.tryCatch(() => fetch(`/api/users/${id}`), E.toError),
TE.flatMap(r => r.ok ? TE.right(r) : TE.left(new Error('Not found'))),
TE.flatMap(r => TE.tryCatch(() => r.json(), E.toError))
)
すぐに効果が出る改善:今日からできる簡単な変更
1. 入れ子になった三項演算子を pipe + fold に置き換える
// Before: Nested ternary nightmare
const message = user === null
? 'No user'
: user.isAdmin
? `Admin: ${user.name}`
: `User: ${user.name}`
// After: Clear case handling
const message = pipe(
O.fromNullable(user),
O.fold(
() => 'No user',
(u) => u.isAdmin ? `Admin: ${u.name}` : `User: ${u.name}`
)
)
2. try-catch を tryCatch に置き換える
// Before: try-catch everywhere
let config
try {
config = JSON.parse(rawConfig)
} catch {
config = defaultConfig
}
// After: One-liner
const config = pipe(
E.tryCatch(() => JSON.parse(rawConfig), () => 'parse error'),
E.getOrElse(() => defaultConfig)
)
3. undefined を返す処理を Option に置き換える
// Before: Caller might forget to check
function findUser(id: string): User | undefined {
return users.find(u => u.id === id)
}
// After: Type forces caller to handle missing case
function findUser(id: string): O.Option<User> {
return O.fromNullable(users.find(u => u.id === id))
}
4. エラー文字列を型付きエラーに置き換える
// Before: Just strings
function validate(data: unknown): E.Either<string, User> {
// ...
return E.left('validation failed')
}
// After: Structured errors
type ValidationError = {
field: string
message: string
}
function validate(data: unknown): E.Either<ValidationError, User> {
// ...
return E.left({ field: 'email', message: 'Invalid format' })
}
5. エラー型に const アサーションを使う
// Create specific error types without classes
const NotFound = (id: string) => ({ _tag: 'NotFound' as const, id })
const Unauthorized = { _tag: 'Unauthorized' as const }
const ValidationFailed = (errors: string[]) =>
({ _tag: 'ValidationFailed' as const, errors })
type AppError =
| ReturnType<typeof NotFound>
| typeof Unauthorized
| ReturnType<typeof ValidationFailed>
// Now you can pattern match
const handleError = (error: AppError): string => {
switch (error._tag) {
case 'NotFound': return `Item ${error.id} not found`
case 'Unauthorized': return 'Please log in'
case 'ValidationFailed': return error.errors.join(', ')
}
}
よくあるリファクタリング:変更前と変更後
コールバック地獄を Pipe に置き換える
// Before
fetchUser(id, (user) => {
if (!user) return handleNoUser()
fetchPosts(user.id, (posts) => {
if (!posts) return handleNoPosts()
fetchComments(posts[0].id, (comments) => {
render(user, posts, comments)
})
})
})
// After (with TaskEither for async)
import * as TE from 'fp-ts/TaskEither'
const loadData = (id: string) =>
pipe(
fetchUser(id),
TE.flatMap(user => pipe(
fetchPosts(user.id),
TE.map(posts => ({ user, posts }))
)),
TE.flatMap(({ user, posts }) => pipe(
fetchComments(posts[0].id),
TE.map(comments => ({ user, posts, comments }))
))
)
// Execute
const result = await loadData('123')()
pipe(
result,
E.fold(handleError, ({ user, posts, comments }) => render(user, posts, comments))
)
複数の null チェックを Option チェーンに置き換える
// Before
function getManagerEmail(employee: Employee): string | null {
if (!employee.department) return null
if (!employee.department.manager) return null
if (!employee.department.manager.email) return null
return employee.department.manager.email
}
// After
const getManagerEmail = (employee: Employee): O.Option<string> =>
pipe(
O.fromNullable(employee.department),
O.flatMap(d => O.fromNullable(d.manager)),
O.flatMap(m => O.fromNullable(m.email))
)
// Use it
pipe(
getManagerEmail(employee),
O.fold(
() => sendToDefault(),
(email) => sendTo(email)
)
)
複数のチェックによる検証
// Before: Throws on first error
function validateUser(data: unknown): User {
if (!data || typeof data !== 'object') throw new Error('Must be object')
const obj = data as Record<string, unknown>
if (typeof obj.email !== 'string') throw new Error('Email required')
if (!obj.email.includes('@')) throw new Error('Invalid email')
if (typeof obj.age !== 'number') throw new Error('Age required')
if (obj.age < 0) throw new Error('Age must be positive')
return obj as User
}
// After: Returns first error, type-safe
const validateUser = (data: unknown): E.Either<string, User> =>
pipe(
E.Do,
E.bind('obj', () =>
typeof data === 'object' && data !== null
? E.right(data as Record<string, unknown>)
: E.left('Must be object')
),
E.bind('email', ({ obj }) =>
typeof obj.email === 'string' && obj.email.includes('@')
? E.right(obj.email)
: E.left('Valid email required')
),
E.bind('age', ({ obj }) =>
typeof obj.age === 'number' && obj.age >= 0
? E.right(obj.age)
: E.left('Valid age required')
),
E.map(({ email, age }) => ({ email, age }))
)
Promise チェーンを TaskEither に置き換える
// Before
async function processOrder(orderId: string): Promise<Receipt> {
const order = await fetchOrder(orderId)
if (!order) throw new Error('Order not found')
const validated = await validateOrder(order)
if (!validated.success) throw new Error(validated.error)
const payment = await processPayment(validated.order)
if (!payment.success) throw new Error('Payment failed')
return generateReceipt(payment)
}
// After
const processOrder = (orderId: string): TE.TaskEither<string, Receipt> =>
pipe(
fetchOrderTE(orderId),
TE.flatMap(order =>
order ? TE.right(order) : TE.left('Order not found')
),
TE.flatMap(validateOrderTE),
TE.flatMap(processPaymentTE),
TE.map(generateReceipt)
)
読みやすさのルール
FP パターンを使う前に、次のように考えてください。「若手の開発者にも理解できるだろうか?」
凝りすぎ(避ける)
const result = pipe(
data,
A.filter(flow(prop('status'), equals('active'))),
A.map(flow(prop('value'), multiply(2))),
A.reduce(monoid.concat, monoid.empty),
O.fromPredicate(gt(threshold))
)
ちょうどよい(推奨)
const activeItems = data.filter(item => item.status === 'active')
const doubledValues = activeItems.map(item => item.value * 2)
const total = doubledValues.reduce((sum, val) => sum + val, 0)
const result = total > threshold ? O.some(total) : O.none
中間のアプローチ(多くの場合、最適)
const result = pipe(
data,
A.filter(item => item.status === 'active'),
A.map(item => item.value * 2),
A.reduce(0, (sum, val) => sum + val),
total => total > threshold ? O.some(total) : O.none
)
チートシート
| やりたいこと | 平易な言葉で言うと | fp-ts |
|---|---|---|
| null/undefined に対処する | 「この nullable 値をラップする」 | O.fromNullable(x) |
| 値がない場合のデフォルト | 「何もなければ、これを使う」 | O.getOrElse(() => default) |
| 値がある場合に変換する | 「何かあれば、それを変更する」 | O.map(fn) |
| nullable な処理をつなぐ | 「何かあれば、これを試す」 | O.flatMap(fn) |
| 成功を返す | 「成功しました。この値を返します」 | E.right(value) |
| 失敗を返す | 「失敗しました。理由はこちらです」 | E.left(error) |
| 例外をスローする関数をラップする | 「これを試し、エラーを捕捉する」 | E.tryCatch(fn, onError) |
| どちらの場合にも対処する | 「エラーならこちら、成功ならあちらを実行する」 | E.fold(onLeft, onRight) |
| 処理をつなぐ | 「これを実行し、次にあれを実行する」 | pipe(x, fn1, fn2, fn3) |
次のステップに進むタイミング
こうしたパターンに慣れたら、次の項目を試してみましょう。
- TaskEither - 失敗する可能性のある非同期処理(Promise + try/catch を置き換えます)
- Validation - 最初のエラーで停止せず、すべてのエラーを収集します
- Reader - クラスを使わずに依存性を注入します
- Do 記法 - 複数のバインディングをより簡潔に記述できます
ただし、焦る必要はありません。ここで紹介した基本だけで、実際の場面の 80% に対応できます。ツールを増やす前に、まずはこれらに慣れましょう。
まとめ
- 3 つ以上の操作には pipe を使います
- null になり得る値の連鎖には Option を使います
- 失敗する可能性のある操作には Either を使います
- ラップされた値の変換には map を使います
- 失敗する可能性のある操作の連鎖には flatMap を使います
- 読みやすさが損なわれる場合は FP を避けます
- シンプルに保ちましょう。チームが読めないコードは、良いコードではありません。