mcpField
F
fp-ts Algebraic Data Types and Type Classes

fp-ts Algebraic Data Types and Type Classes

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

fp-ts Algebraic Data Types and Type Classes について

TypeScript 開発者向けに、fp-ts の代数的データ型と型クラスでドメインをモデル化し、値を組み合わせ、各バリアントを網羅的に扱う方法を解説します。

主な機能

  1. 01積型と和型のモデル化
  2. 02判別共用体の定義
  3. 03Eq と Ord インスタンスの利用
  4. 04Semigroup と Monoid による値の結合
  5. 05網羅的なパターンマッチ
  6. 06有効な状態を保つスマートコンストラクター

活用シーン

  1. 01ドメインエンティティと状態のモデル化
  2. 02設定や集約結果の結合
  3. 03ドメイン値の比較と順序付け

インストール

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

$npx skills add https://github.com/whatiskadudoing/fp-ts-skills --skill fp-ts-algebraic-data-types-and-type-classes

インストール後、Claude Code で/fp-ts-algebraic-data-types-and-type-classesを入力して読み込まれたか確認します。

詳細

fp-ts の代数的データ型と型クラス

このスキルでは、TypeScript で堅牢なドメインモデルを構築するための、fp-ts における代数的データ型(ADT)と型クラスについて説明します。

コアコンセプト

代数的データ型とは

代数的データ型(ADT)は、ほかの型を組み合わせて形成される複合型です。基本的な種類は次の2つです。

  • 積型:型を AND で組み合わせます(タプル、レコード)。「ユーザーは名前 AND メールアドレスを持つ」
  • 直和型:型を OR で組み合わせます(判別共用体)。「結果は成功 OR 失敗のいずれか」
型クラスとは

型クラスは、異なる型に実装できる振る舞いを定義します。fp-ts では、特定の型に対するインスタンスを持つインターフェースとして表現されます。

  • Eq:等価性の比較
  • Ord:順序付け/比較
  • Semigroup:同じ型の2つの値の結合
  • Monoid:単位元を持つ半群

積型

積型は、すべての構成要素を同時に含む値を表します。

タプル

タプルは、型が位置ごとに指定された固定長の配列です。

import { pipe } from 'fp-ts/function'
import * as T from 'fp-ts/Tuple'

// Basic tuple type
type Point2D = readonly [number, number]
type NamedValue = readonly [string, number]

const point: Point2D = [10, 20]
const item: NamedValue = ['score', 100]

// Accessing tuple elements
const [x, y] = point
const [label, value] = item

// Tuple operations from fp-ts
const first = T.fst(point)   // 10
const second = T.snd(point)  // 20

// Map over the first element
const doubled = T.mapFst((n: number) => n * 2)(point) // [20, 20]

// Map over the second element
const incremented = T.mapSnd((n: number) => n + 1)(point) // [10, 21]

// Bimap: transform both elements
const transformed = T.bimap(
  (n: number) => n.toString(),
  (n: number) => n * 2
)(point) // ['20', 20]
レコード(オブジェクト型)

レコードは、TypeScript で最も一般的な積型です。

// Domain model as a product type
interface User {
  readonly id: string
  readonly email: string
  readonly name: string
  readonly createdAt: Date
}

// All fields must be present - this is what makes it a "product"
const user: User = {
  id: '123',
  email: '[email protected]',
  name: 'John Doe',
  createdAt: new Date()
}

// Nested product types
interface Order {
  readonly id: string
  readonly customer: User
  readonly items: ReadonlyArray<OrderItem>
  readonly shipping: ShippingAddress
  readonly billing: BillingInfo
}

interface OrderItem {
  readonly productId: string
  readonly quantity: number
  readonly unitPrice: number
}

interface ShippingAddress {
  readonly street: string
  readonly city: string
  readonly postalCode: string
  readonly country: string
}

interface BillingInfo {
  readonly method: PaymentMethod
  readonly cardLast4?: string
}
積型を使う場面

次の場合は積型を使います。

  • エンティティに必要なすべてのデータが有効である必要がある
  • データが自然にひとまとまりになる
  • データベースのエンティティや API のペイロードをモデル化する
  • 構成要素が常に一緒に存在する
// Good: All fields are required for a valid config
interface DatabaseConfig {
  readonly host: string
  readonly port: number
  readonly database: string
  readonly username: string
  readonly password: string
}

// Good: Coordinate always needs both x and y
interface Coordinate {
  readonly x: number
  readonly y: number
}

// Good: Money needs both amount and currency
interface Money {
  readonly amount: number
  readonly currency: string
}

直和型(判別共用体)

直和型は、複数のバリアントのいずれか1つになり得る値を表します。

基本的な判別共用体
// A payment can be ONE OF these types
type PaymentMethod =
  | { readonly _tag: 'CreditCard'; readonly cardNumber: string; readonly expiry: string }
  | { readonly _tag: 'PayPal'; readonly email: string }
  | { readonly _tag: 'BankTransfer'; readonly accountNumber: string; readonly routingNumber: string }
  | { readonly _tag: 'Crypto'; readonly walletAddress: string; readonly network: string }

// Creating values
const creditCard: PaymentMethod = {
  _tag: 'CreditCard',
  cardNumber: '4111111111111111',
  expiry: '12/25'
}

const paypal: PaymentMethod = {
  _tag: 'PayPal',
  email: '[email protected]'
}
fold/match によるパターンマッチング
import { pipe } from 'fp-ts/function'

// Define a fold function for exhaustive pattern matching
const foldPaymentMethod = <R>(handlers: {
  CreditCard: (cardNumber: string, expiry: string) => R
  PayPal: (email: string) => R
  BankTransfer: (accountNumber: string, routingNumber: string) => R
  Crypto: (walletAddress: string, network: string) => R
}) => (payment: PaymentMethod): R => {
  switch (payment._tag) {
    case 'CreditCard':
      return handlers.CreditCard(payment.cardNumber, payment.expiry)
    case 'PayPal':
      return handlers.PayPal(payment.email)
    case 'BankTransfer':
      return handlers.BankTransfer(payment.accountNumber, payment.routingNumber)
    case 'Crypto':
      return handlers.Crypto(payment.walletAddress, payment.network)
  }
}

// Usage
const getPaymentLabel = foldPaymentMethod({
  CreditCard: (num, _) => `Card ending in ${num.slice(-4)}`,
  PayPal: (email) => `PayPal: ${email}`,
  BankTransfer: (acc, _) => `Bank: ${acc.slice(-4)}`,
  Crypto: (wallet, network) => `${network}: ${wallet.slice(0, 8)}...`
})

const label = getPaymentLabel(creditCard) // "Card ending in 1111"
直和型のスマートコンストラクター
// Define the union
type RemoteData<E, A> =
  | { readonly _tag: 'NotAsked' }
  | { readonly _tag: 'Loading' }
  | { readonly _tag: 'Failure'; readonly error: E }
  | { readonly _tag: 'Success'; readonly data: A }

// Smart constructors
const notAsked: RemoteData<never, never> = { _tag: 'NotAsked' }
const loading: RemoteData<never, never> = { _tag: 'Loading' }
const failure = <E>(error: E): RemoteData<E, never> => ({ _tag: 'Failure', error })
const success = <A>(data: A): RemoteData<never, A> => ({ _tag: 'Success', data })

// Type guards
const isNotAsked = <E, A>(rd: RemoteData<E, A>): rd is { _tag: 'NotAsked' } =>
  rd._tag === 'NotAsked'
const isLoading = <E, A>(rd: RemoteData<E, A>): rd is { _tag: 'Loading' } =>
  rd._tag === 'Loading'
const isFailure = <E, A>(rd: RemoteData<E, A>): rd is { _tag: 'Failure'; error: E } =>
  rd._tag === 'Failure'
const isSuccess = <E, A>(rd: RemoteData<E, A>): rd is { _tag: 'Success'; data: A } =>
  rd._tag === 'Success'

// Fold for exhaustive matching
const fold = <E, A, R>(handlers: {
  NotAsked: () => R
  Loading: () => R
  Failure: (error: E) => R
  Success: (data: A) => R
}) => (rd: RemoteData<E, A>): R => {
  switch (rd._tag) {
    case 'NotAsked': return handlers.NotAsked()
    case 'Loading': return handlers.Loading()
    case 'Failure': return handlers.Failure(rd.error)
    case 'Success': return handlers.Success(rd.data)
  }
}

// Usage in React
const UserProfile = ({ data }: { data: RemoteData<Error, User> }) =>
  pipe(
    data,
    fold({
      NotAsked: () => <button>Load Profile</button>,
      Loading: () => <Spinner />,
      Failure: (err) => <ErrorMessage error={err} />,
      Success: (user) => <ProfileCard user={user} />
    })
  )
直和型を使う場面

次の場合は直和型を使います。

  • 値が異なる状態になり得る
  • バリアントごとにデータの形状が異なる
  • すべてのケースを網羅的に処理する必要がある
  • ステートマシンやワークフローをモデル化する
// Good: Order status is mutually exclusive
type OrderStatus =
  | { readonly _tag: 'Pending' }
  | { readonly _tag: 'Confirmed'; readonly confirmedAt: Date }
  | { readonly _tag: 'Shipped'; readonly trackingNumber: string; readonly shippedAt: Date }
  | { readonly _tag: 'Delivered'; readonly deliveredAt: Date }
  | { readonly _tag: 'Cancelled'; readonly reason: string; readonly cancelledAt: Date }

// Good: API response states
type ApiResponse<T> =
  | { readonly _tag: 'Idle' }
  | { readonly _tag: 'Loading' }
  | { readonly _tag: 'Error'; readonly message: string; readonly code: number }
  | { readonly _tag: 'Success'; readonly data: T }

// Good: Form field validation state
type FieldState<T> =
  | { readonly _tag: 'Pristine'; readonly value: T }
  | { readonly _tag: 'Valid'; readonly value: T }
  | { readonly _tag: 'Invalid'; readonly value: T; readonly errors: ReadonlyArray<string> }

半群

Semigroup は、同じ型の2つの値をどのように結合するかを定義します。結合則を満たす必要があります:combine(combine(a, b), c) === combine(a, combine(b, c))

Semigroup の基本的な使い方
import * as S from 'fp-ts/Semigroup'
import * as N from 'fp-ts/number'
import * as Str from 'fp-ts/string'
import { pipe } from 'fp-ts/function'

// Built-in semigroups
const sumResult = S.concatAll(N.SemigroupSum)(0)([1, 2, 3, 4]) // 10
const productResult = S.concatAll(N.SemigroupProduct)(1)([1, 2, 3, 4]) // 24
const stringResult = S.concatAll(Str.Semigroup)('')(['Hello', ' ', 'World']) // 'Hello World'

// Combining two values
const sum = N.SemigroupSum.concat(5, 3) // 8
const product = N.SemigroupProduct.concat(5, 3) // 15
カスタム Semigroup インスタンス
import * as S from 'fp-ts/Semigroup'
import { pipe } from 'fp-ts/function'

// Semigroup for taking the first value
const first = <A>(): S.Semigroup<A> => ({
  concat: (x, _) => x
})

// Semigroup for taking the last value
const last = <A>(): S.Semigroup<A> => ({
  concat: (_, y) => y
})

// Semigroup for max value
const max: S.Semigroup<number> = {
  concat: (x, y) => Math.max(x, y)
}

// Semigroup for min value
const min: S.Semigroup<number> = {
  concat: (x, y) => Math.min(x, y)
}
実践例:設定のマージ
import * as S from 'fp-ts/Semigroup'
import { pipe } from 'fp-ts/function'

interface ServerConfig {
  readonly host: string
  readonly port: number
  readonly timeout: number
  readonly retries: number
  readonly features: ReadonlyArray<string>
}

// Semigroup that merges configs (later values override)
const ServerConfigSemigroup: S.Semigroup<ServerConfig> = S.struct({
  host: S.last<string>(),           // Last value wins
  port: S.last<number>(),           // Last value wins
  timeout: S.max(N.Ord),            // Take maximum timeout
  retries: S.max(N.Ord),            // Take maximum retries
  features: {                        // Concatenate arrays
    concat: (x, y) => [...new Set([...x, ...y])]
  }
})

// Default config
const defaultConfig: ServerConfig = {
  host: 'localhost',
  port: 3000,
  timeout: 5000,
  retries: 3,
  features: ['logging']
}

// Environment-specific overrides
const prodOverrides: ServerConfig = {
  host: 'api.example.com',
  port: 443,
  timeout: 10000,
  retries: 5,
  features: ['monitoring', 'caching']
}

// Merge configs
const finalConfig = ServerConfigSemigroup.concat(defaultConfig, prodOverrides)
// {
//   host: 'api.example.com',
//   port: 443,
//   timeout: 10000,
//   retries: 5,
//   features: ['logging', 'monitoring', 'caching']
// }

// Merge multiple configs
const configs = [defaultConfig, devOverrides, envOverrides, cliOverrides]
const mergedConfig = S.concatAll(ServerConfigSemigroup)(defaultConfig)(configs)
オプショナル値の Semigroup
import * as S from 'fp-ts/Semigroup'
import * as O from 'fp-ts/Option'

// Semigroup for Option that prefers Some values
const getOptionSemigroup = <A>(S: S.Semigroup<A>): S.Semigroup<O.Option<A>> =>
  O.getMonoid(S)

// Or use the built-in
import { getApplySemigroup } from 'fp-ts/Apply'
import { Applicative } from 'fp-ts/Option'

const optionStringSemigroup = getApplySemigroup(Applicative)(Str.Semigroup)

// Example: merging optional configs
interface PartialConfig {
  readonly host?: string
  readonly port?: number
}

const mergePartialConfigs = (a: PartialConfig, b: PartialConfig): PartialConfig => ({
  host: b.host ?? a.host,
  port: b.port ?? a.port
})

モノイド

Monoid は単位元(空)を持つ半群です。空の要素は次を満たします:concat(empty, a) === a および concat(a, empty) === a

Monoid の基本的な使い方
import * as M from 'fp-ts/Monoid'
import * as N from 'fp-ts/number'
import * as Str from 'fp-ts/string'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

// Built-in monoids
const sum = M.concatAll(N.MonoidSum)([1, 2, 3, 4, 5]) // 15
const product = M.concatAll(N.MonoidProduct)([1, 2, 3, 4, 5]) // 120
const concatenated = M.concatAll(Str.Monoid)(['a', 'b', 'c']) // 'abc'

// Empty values
N.MonoidSum.empty // 0
N.MonoidProduct.empty // 1
Str.Monoid.empty // ''
A.getMonoid<number>().empty // []
カスタム Monoid インスタンス
import * as M from 'fp-ts/Monoid'
import { pipe } from 'fp-ts/function'

// Monoid for boolean AND
const MonoidAll: M.Monoid<boolean> = {
  concat: (x, y) => x && y,
  empty: true
}

// Monoid for boolean OR
const MonoidAny: M.Monoid<boolean> = {
  concat: (x, y) => x || y,
  empty: false
}

// Usage: check if all validations pass
const allValid = M.concatAll(MonoidAll)([true, true, false, true]) // false
const anyValid = M.concatAll(MonoidAny)([false, false, true, false]) // true
実践例:結果の結合
import * as M from 'fp-ts/Monoid'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

// Aggregation result type
interface AggregationResult {
  readonly totalCount: number
  readonly successCount: number
  readonly errorCount: number
  readonly errors: ReadonlyArray<string>
  readonly processingTimeMs: number
}

// Monoid for combining aggregation results
const AggregationResultMonoid: M.Monoid<AggregationResult> = {
  concat: (a, b) => ({
    totalCount: a.totalCount + b.totalCount,
    successCount: a.successCount + b.successCount,
    errorCount: a.errorCount + b.errorCount,
    errors: [...a.errors, ...b.errors],
    processingTimeMs: a.processingTimeMs + b.processingTimeMs
  }),
  empty: {
    totalCount: 0,
    successCount: 0,
    errorCount: 0,
    errors: [],
    processingTimeMs: 0
  }
}

// Process items and combine results
const processItem = (item: Item): AggregationResult => {
  const start = Date.now()
  try {
    doProcessing(item)
    return {
      totalCount: 1,
      successCount: 1,
      errorCount: 0,
      errors: [],
      processingTimeMs: Date.now() - start
    }
  } catch (e) {
    return {
      totalCount: 1,
      successCount: 0,
      errorCount: 1,
      errors: [e instanceof Error ? e.message : 'Unknown error'],
      processingTimeMs: Date.now() - start
    }
  }
}

// Combine all results
const processAll = (items: ReadonlyArray<Item>): AggregationResult =>
  pipe(
    items,
    A.map(processItem),
    M.concatAll(AggregationResultMonoid)
  )
統計情報の Monoid
import * as M from 'fp-ts/Monoid'

interface Statistics {
  readonly count: number
  readonly sum: number
  readonly min: number
  readonly max: number
}

const StatisticsMonoid: M.Monoid<Statistics> = {
  concat: (a, b) => ({
    count: a.count + b.count,
    sum: a.sum + b.sum,
    min: Math.min(a.min, b.min),
    max: Math.max(a.max, b.max)
  }),
  empty: {
    count: 0,
    sum: 0,
    min: Infinity,
    max: -Infinity
  }
}

// Create statistics from a single value
const fromNumber = (n: number): Statistics => ({
  count: 1,
  sum: n,
  min: n,
  max: n
})

// Calculate statistics for an array
const calculateStats = (numbers: ReadonlyArray<number>): Statistics =>
  pipe(
    numbers,
    A.map(fromNumber),
    M.concatAll(StatisticsMonoid)
  )

const stats = calculateStats([5, 2, 8, 1, 9])
// { count: 5, sum: 25, min: 1, max: 9 }

// Can also compute average
const average = (s: Statistics): number =>
  s.count === 0 ? 0 : s.sum / s.count

Eq 型クラス

Eq は型の等価性比較を定義します。

Eq の基本的な使い方
import * as Eq from 'fp-ts/Eq'
import * as N from 'fp-ts/number'
import * as Str from 'fp-ts/string'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

// Built-in Eq instances
N.Eq.equals(1, 1) // true
Str.Eq.equals('hello', 'hello') // true

// Eq for arrays
const numberArrayEq = A.getEq(N.Eq)
numberArrayEq.equals([1, 2, 3], [1, 2, 3]) // true
numberArrayEq.equals([1, 2, 3], [1, 2]) // false
カスタム Eq インスタンス
import * as Eq from 'fp-ts/Eq'
import { pipe } from 'fp-ts/function'

interface User {
  readonly id: string
  readonly email: string
  readonly name: string
}

// Eq by ID only
const UserEqById: Eq.Eq<User> = Eq.contramap((user: User) => user.id)(Str.Eq)

// Eq by all fields
const UserEqFull: Eq.Eq<User> = Eq.struct({
  id: Str.Eq,
  email: Str.Eq,
  name: Str.Eq
})

// Usage
const user1: User = { id: '1', email: '[email protected]', name: 'Alice' }
const user2: User = { id: '1', email: '[email protected]', name: 'Alice Updated' }

UserEqById.equals(user1, user2) // true (same ID)
UserEqFull.equals(user1, user2) // false (different email)
実践例:重複排除
import * as Eq from 'fp-ts/Eq'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

interface Product {
  readonly sku: string
  readonly name: string
  readonly price: number
}

const ProductEqBySku: Eq.Eq<Product> = pipe(
  Str.Eq,
  Eq.contramap((p: Product) => p.sku)
)

// Remove duplicates by SKU
const uniqueProducts = A.uniq(ProductEqBySku)

const products: Product[] = [
  { sku: 'A001', name: 'Widget', price: 10 },
  { sku: 'A002', name: 'Gadget', price: 20 },
  { sku: 'A001', name: 'Widget Updated', price: 15 }, // Duplicate SKU
]

const unique = uniqueProducts(products)
// [{ sku: 'A001', name: 'Widget', price: 10 }, { sku: 'A002', name: 'Gadget', price: 20 }]

// Check if array contains element
const hasProduct = A.elem(ProductEqBySku)
hasProduct({ sku: 'A001', name: '', price: 0 })(products) // true
大文字と小文字を区別しない文字列の Eq
import * as Eq from 'fp-ts/Eq'

const EqCaseInsensitive: Eq.Eq<string> = {
  equals: (x, y) => x.toLowerCase() === y.toLowerCase()
}

EqCaseInsensitive.equals('Hello', 'hello') // true
EqCaseInsensitive.equals('WORLD', 'world') // true

// Use with user emails
const UserEqByEmail: Eq.Eq<User> = pipe(
  EqCaseInsensitive,
  Eq.contramap((u: User) => u.email)
)

Ord 型クラス

Ord は Eq を拡張し、順序付け/比較機能を追加します。

Ord の基本的な使い方
import * as Ord from 'fp-ts/Ord'
import * as N from 'fp-ts/number'
import * as Str from 'fp-ts/string'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

// Built-in Ord instances
Ord.lt(N.Ord)(1, 2) // true (1 < 2)
Ord.gt(N.Ord)(5, 3) // true (5 > 3)
Ord.leq(N.Ord)(2, 2) // true (2 <= 2)
Ord.geq(N.Ord)(3, 2) // true (3 >= 2)

// Sorting
const numbers = [3, 1, 4, 1, 5, 9, 2, 6]
const sorted = A.sort(N.Ord)(numbers) // [1, 1, 2, 3, 4, 5, 6, 9]

// Reverse order
const descending = A.sort(Ord.reverse(N.Ord))(numbers) // [9, 6, 5, 4, 3, 2, 1, 1]

// Min and max
const minimum = Ord.min(N.Ord)(5, 3) // 3
const maximum = Ord.max(N.Ord)(5, 3) // 5
カスタム Ord インスタンス
import * as Ord from 'fp-ts/Ord'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

interface Product {
  readonly name: string
  readonly price: number
  readonly rating: number
  readonly createdAt: Date
}

// Ord by price
const OrdByPrice: Ord.Ord<Product> = pipe(
  N.Ord,
  Ord.contramap((p: Product) => p.price)
)

// Ord by rating (descending - highest first)
const OrdByRatingDesc: Ord.Ord<Product> = pipe(
  N.Ord,
  Ord.contramap((p: Product) => p.rating),
  Ord.reverse
)

// Ord by date (newest first)
const OrdByDateDesc: Ord.Ord<Product> = pipe(
  N.Ord,
  Ord.contramap((p: Product) => p.createdAt.getTime()),
  Ord.reverse
)

// Sort products by price
const sortByPrice = A.sort(OrdByPrice)

// Sort by rating (highest first)
const sortByRating = A.sort(OrdByRatingDesc)
複合的な順序付け
import * as Ord from 'fp-ts/Ord'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'
import * as M from 'fp-ts/Monoid'

interface Employee {
  readonly department: string
  readonly name: string
  readonly salary: number
  readonly startDate: Date
}

// Primary: department (alphabetical)
// Secondary: salary (descending)
// Tertiary: name (alphabetical)

const OrdByDepartment: Ord.Ord<Employee> = pipe(
  Str.Ord,
  Ord.contramap((e: Employee) => e.department)
)

const OrdBySalaryDesc: Ord.Ord<Employee> = pipe(
  N.Ord,
  Ord.contramap((e: Employee) => e.salary),
  Ord.reverse
)

const OrdByName: Ord.Ord<Employee> = pipe(
  Str.Ord,
  Ord.contramap((e: Employee) => e.name)
)

// Combine orderings with monoid
const EmployeeOrd: Ord.Ord<Employee> = M.concatAll(Ord.getMonoid<Employee>())([
  OrdByDepartment,
  OrdBySalaryDesc,
  OrdByName
])

const employees: Employee[] = [
  { department: 'Engineering', name: 'Alice', salary: 100000, startDate: new Date() },
  { department: 'Engineering', name: 'Bob', salary: 120000, startDate: new Date() },
  { department: 'Sales', name: 'Charlie', salary: 90000, startDate: new Date() },
  { department: 'Engineering', name: 'Diana', salary: 120000, startDate: new Date() },
]

const sorted = A.sort(EmployeeOrd)(employees)
// Engineering: Bob (120k), Diana (120k - alphabetical), Alice (100k)
// Sales: Charlie (90k)
実践例:ソートとフィルタリング
import * as Ord from 'fp-ts/Ord'
import * as A from 'fp-ts/Array'
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'

interface Task {
  readonly id: string
  readonly title: string
  readonly priority: 'low' | 'medium' | 'high' | 'critical'
  readonly dueDate: O.Option<Date>
  readonly completed: boolean
}

// Priority ordering (critical > high > medium > low)
const priorityOrder: Record<Task['priority'], number> = {
  critical: 4,
  high: 3,
  medium: 2,
  low: 1
}

const OrdByPriority: Ord.Ord<Task> = pipe(
  N.Ord,
  Ord.contramap((t: Task) => priorityOrder[t.priority]),
  Ord.reverse // Highest priority first
)

// Due date ordering (soonest first, no date last)
const OrdByDueDate: Ord.Ord<Task> = pipe(
  O.getOrd(N.Ord),
  Ord.contramap((t: Task) => pipe(t.dueDate, O.map(d => d.getTime())))
)

// Combined: incomplete first, then by priority, then by due date
const TaskOrd: Ord.Ord<Task> = M.concatAll(Ord.getMonoid<Task>())([
  pipe(
    Ord.trivial, // Boolean doesn't have natural ordering
    Ord.contramap((t: Task) => t.completed ? 1 : 0) // Incomplete (0) before complete (1)
  ),
  OrdByPriority,
  OrdByDueDate
])

// Get top N tasks
const getTopTasks = (n: number) => (tasks: Task[]): Task[] =>
  pipe(
    tasks,
    A.filter(t => !t.completed),
    A.sort(TaskOrd),
    A.takeLeft(n)
  )

fp-ts の型クラスインスタンスの使用

モジュールからインスタンスを取得する
import * as O from 'fp-ts/Option'
import * as E from 'fp-ts/Either'
import * as A from 'fp-ts/Array'
import * as R from 'fp-ts/Record'
import * as NEA from 'fp-ts/NonEmptyArray'

// Each module exports type class instances

// Option instances
O.Eq        // Eq<Option<A>> given Eq<A>
O.Ord       // Ord<Option<A>> given Ord<A>
O.Semigroup // Semigroup using Apply
O.Monoid    // Monoid with None as empty

// Array instances
A.getEq     // (Eq<A>) => Eq<Array<A>>
A.getOrd    // (Ord<A>) => Ord<Array<A>>
A.getMonoid // <A>() => Monoid<Array<A>>
A.getSemigroup // <A>() => Semigroup<NonEmptyArray<A>>

// Record instances
R.getEq     // Eq for records
R.getMonoid // Monoid for records
型クラスを組み合わせる
import * as Eq from 'fp-ts/Eq'
import * as Ord from 'fp-ts/Ord'
import * as S from 'fp-ts/Semigroup'
import * as M from 'fp-ts/Monoid'
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'

interface CartItem {
  readonly productId: string
  readonly quantity: number
  readonly price: number
}

// Eq for cart items (by product ID)
const CartItemEq: Eq.Eq<CartItem> = pipe(
  Str.Eq,
  Eq.contramap((item: CartItem) => item.productId)
)

// Semigroup that merges quantities for same product
const CartItemSemigroup: S.Semigroup<CartItem> = {
  concat: (a, b) => ({
    productId: a.productId,
    quantity: a.quantity + b.quantity,
    price: a.price // Keep original price
  })
}

// Merge cart items, combining quantities for duplicates
const mergeCartItems = (items: CartItem[]): CartItem[] => {
  const grouped = items.reduce((acc, item) => {
    const existing = acc.get(item.productId)
    if (existing) {
      acc.set(item.productId, CartItemSemigroup.concat(existing, item))
    } else {
      acc.set(item.productId, item)
    }
    return acc
  }, new Map<string, CartItem>())

  return Array.from(grouped.values())
}

// Calculate cart total using Monoid
interface CartTotal {
  readonly itemCount: number
  readonly subtotal: number
}

const CartTotalMonoid: M.Monoid<CartTotal> = {
  concat: (a, b) => ({
    itemCount: a.itemCount + b.itemCount,
    subtotal: a.subtotal + b.subtotal
  }),
  empty: { itemCount: 0, subtotal: 0 }
}

const calculateTotal = (items: CartItem[]): CartTotal =>
  pipe(
    items,
    A.map(item => ({
      itemCount: item.quantity,
      subtotal: item.quantity * item.price
    })),
    M.concatAll(CartTotalMonoid)
  )
ドメイン固有の型クラスを構築する
import * as Eq from 'fp-ts/Eq'
import * as Ord from 'fp-ts/Ord'
import * as S from 'fp-ts/Semigroup'
import * as M from 'fp-ts/Monoid'
import { pipe } from 'fp-ts/function'

// Domain: Money handling

interface Money {
  readonly amount: number
  readonly currency: string
}

// Only allow operations on same currency
const MoneyEq: Eq.Eq<Money> = Eq.struct({
  amount: N.Eq,
  currency: Str.Eq
})

// Compare money amounts (same currency only)
const MoneyOrd: Ord.Ord<Money> = pipe(
  N.Ord,
  Ord.contramap((m: Money) => m.amount)
)

// Add money (throws if different currencies)
const MoneySemigroup = (currency: string): S.Semigroup<Money> => ({
  concat: (a, b) => {
    if (a.currency !== currency || b.currency !== currency) {
      throw new Error('Cannot combine different currencies')
    }
    return { amount: a.amount + b.amount, currency }
  }
})

// Safe money addition using Option
const addMoney = (a: Money, b: Money): O.Option<Money> =>
  a.currency === b.currency
    ? O.some({ amount: a.amount + b.amount, currency: a.currency })
    : O.none

// Monoid for specific currency
const MoneyMonoid = (currency: string): M.Monoid<Money> => ({
  ...MoneySemigroup(currency),
  empty: { amount: 0, currency }
})

// Sum all USD amounts
const totalUSD = (amounts: Money[]): Money =>
  pipe(
    amounts,
    A.filter(m => m.currency === 'USD'),
    M.concatAll(MoneyMonoid('USD'))
  )

実践的なドメインモデリングの例

例1:E コマースの注文システム
import * as E from 'fp-ts/Either'
import * as O from 'fp-ts/Option'
import * as A from 'fp-ts/Array'
import * as M from 'fp-ts/Monoid'
import { pipe } from 'fp-ts/function'

// Sum type for order status
type OrderStatus =
  | { readonly _tag: 'Draft' }
  | { readonly _tag: 'Pending'; readonly submittedAt: Date }
  | { readonly _tag: 'Paid'; readonly paidAt: Date; readonly transactionId: string }
  | { readonly _tag: 'Shipped'; readonly shippedAt: Date; readonly trackingNumber: string }
  | { readonly _tag: 'Delivered'; readonly deliveredAt: Date }
  | { readonly _tag: 'Cancelled'; readonly cancelledAt: Date; readonly reason: string }
  | { readonly _tag: 'Refunded'; readonly refundedAt: Date; readonly refundAmount: number }

// Smart constructors
const OrderStatus = {
  draft: (): OrderStatus => ({ _tag: 'Draft' }),
  pending: (submittedAt: Date): OrderStatus => ({ _tag: 'Pending', submittedAt }),
  paid: (paidAt: Date, transactionId: string): OrderStatus => ({ _tag: 'Paid', paidAt, transactionId }),
  shipped: (shippedAt: Date, trackingNumber: string): OrderStatus => ({ _tag: 'Shipped', shippedAt, trackingNumber }),
  delivered: (deliveredAt: Date): OrderStatus => ({ _tag: 'Delivered', deliveredAt }),
  cancelled: (cancelledAt: Date, reason: string): OrderStatus => ({ _tag: 'Cancelled', cancelledAt, reason }),
  refunded: (refundedAt: Date, refundAmount: number): OrderStatus => ({ _tag: 'Refunded', refundedAt, refundAmount })
}

// Pattern matching
const foldOrderStatus = <R>(handlers: {
  Draft: () => R
  Pending: (submittedAt: Date) => R
  Paid: (paidAt: Date, transactionId: string) => R
  Shipped: (shippedAt: Date, trackingNumber: string) => R
  Delivered: (deliveredAt: Date) => R
  Cancelled: (cancelledAt: Date, reason: string) => R
  Refunded: (refundedAt: Date, refundAmount: number) => R
}) => (status: OrderStatus): R => {
  switch (status._tag) {
    case 'Draft': return handlers.Draft()
    case 'Pending': return handlers.Pending(status.submittedAt)
    case 'Paid': return handlers.Paid(status.paidAt, status.transactionId)
    case 'Shipped': return handlers.Shipped(status.shippedAt, status.trackingNumber)
    case 'Delivered': return handlers.Delivered(status.deliveredAt)
    case 'Cancelled': return handlers.Cancelled(status.cancelledAt, status.reason)
    case 'Refunded': return handlers.Refunded(status.refundedAt, status.refundAmount)
  }
}

// Product type for order
interface Order {
  readonly id: string
  readonly customerId: string
  readonly items: ReadonlyArray<OrderItem>
  readonly status: OrderStatus
  readonly shippingAddress: Address
  readonly billingAddress: Address
  readonly createdAt: Date
  readonly updatedAt: Date
}

interface OrderItem {
  readonly productId: string
  readonly productName: string
  readonly quantity: number
  readonly unitPrice: number
  readonly discount: number
}

interface Address {
  readonly line1: string
  readonly line2: O.Option<string>
  readonly city: string
  readonly state: string
  readonly postalCode: string
  readonly country: string
}

// Monoid for order totals
interface OrderTotals {
  readonly subtotal: number
  readonly discount: number
  readonly tax: number
  readonly shipping: number
  readonly total: number
}

const OrderTotalsMonoid: M.Monoid<OrderTotals> = M.struct({
  subtotal: N.MonoidSum,
  discount: N.MonoidSum,
  tax: N.MonoidSum,
  shipping: N.MonoidSum,
  total: N.MonoidSum
})

const calculateItemTotal = (item: OrderItem): OrderTotals => {
  const subtotal = item.quantity * item.unitPrice
  const discount = item.discount
  return {
    subtotal,
    discount,
    tax: 0, // Calculated separately
    shipping: 0,
    total: subtotal - discount
  }
}

const calculateOrderTotals = (order: Order, taxRate: number, shippingCost: number): OrderTotals => {
  const itemTotals = pipe(
    order.items,
    A.map(calculateItemTotal),
    M.concatAll(OrderTotalsMonoid)
  )

  const tax = (itemTotals.subtotal - itemTotals.discount) * taxRate
  const total = itemTotals.subtotal - itemTotals.discount + tax + shippingCost

  return {
    ...itemTotals,
    tax,
    shipping: shippingCost,
    total
  }
}

// State transitions with validation
const canTransition = (from: OrderStatus, to: OrderStatus['_tag']): boolean =>
  foldOrderStatus({
    Draft: () => to === 'Pending' || to === 'Cancelled',
    Pending: () => to === 'Paid' || to === 'Cancelled',
    Paid: () => to === 'Shipped' || to === 'Refunded',
    Shipped: () => to === 'Delivered' || to === 'Refunded',
    Delivered: () => to === 'Refunded',
    Cancelled: () => false,
    Refunded: () => false
  })(from)
例2:設定管理
import * as S from 'fp-ts/Semigroup'
import * as O from 'fp-ts/Option'
import * as A from 'fp-ts/Array'
import { pipe } from 'fp-ts/function'

// Sum type for log level
type LogLevel = 'debug' | 'info' | 'warn' | 'error'

const LogLevelOrd: Ord.Ord<LogLevel> = pipe(
  N.Ord,
  Ord.contramap((level: LogLevel) => {
    const order: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }
    return order[level]
  })
)

// Product type for app config
interface AppConfig {
  readonly server: ServerConfig
  readonly database: DatabaseConfig
  readonly logging: LoggingConfig
  readonly features: FeatureFlags
}

interface ServerConfig {
  readonly host: string
  readonly port: number
  readonly timeout: number
  readonly maxConnections: number
}

interface DatabaseConfig {
  readonly host: string
  readonly port: number
  readonly database: string
  readonly poolSize: number
  readonly ssl: boolean
}

interface LoggingConfig {
  readonly level: LogLevel
  readonly format: 'json' | 'text'
  readonly outputs: ReadonlyArray<string>
}

interface FeatureFlags {
  readonly [key: string]: boolean
}

// Semigroups for merging configs (later values override)
const ServerConfigSemigroup: S.Semigroup<ServerConfig> = S.struct({
  host: S.last<string>(),
  port: S.last<number>(),
  timeout: S.max(N.Ord), // Take higher timeout
  maxConnections: S.max(N.Ord) // Take higher max connections
})

const DatabaseConfigSemigroup: S.Semigroup<DatabaseConfig> = S.struct({
  host: S.last<string>(),
  port: S.last<number>(),
  database: S.last<string>(),
  poolSize: S.max(N.Ord),
  ssl: { concat: (a, b) => a || b } // SSL if either enables it
})

const LoggingConfigSemigroup: S.Semigroup<LoggingConfig> = {
  concat: (a, b) => ({
    level: Ord.min(LogLevelOrd)(a.level, b.level), // Most verbose level
    format: b.format, // Last format wins
    outputs: [...new Set([...a.outputs, ...b.outputs])] // Union of outputs
  })
}

const FeatureFlagsSemigroup: S.Semigroup<FeatureFlags> = {
  concat: (a, b) => ({ ...a, ...b }) // Later flags override
}

const AppConfigSemigroup: S.Semigroup<AppConfig> = S.struct({
  server: ServerConfigSemigroup,
  database: DatabaseConfigSemigroup,
  logging: LoggingConfigSemigroup,
  features: FeatureFlagsSemigroup
})

// Load and merge configs from multiple sources
const loadConfig = (sources: AppConfig[]): AppConfig =>
  pipe(
    sources,
    A.reduce(defaultConfig, AppConfigSemigroup.concat)
  )

// Example usage
const defaultConfig: AppConfig = {
  server: { host: 'localhost', port: 3000, timeout: 5000, maxConnections: 100 },
  database: { host: 'localhost', port: 5432, database: 'app', poolSize: 10, ssl: false },
  logging: { level: 'info', format: 'text', outputs: ['console'] },
  features: {}
}

const envConfig: AppConfig = {
  server: { host: 'api.prod.com', port: 443, timeout: 10000, maxConnections: 1000 },
  database: { host: 'db.prod.com', port: 5432, database: 'app_prod', poolSize: 50, ssl: true },
  logging: { level: 'warn', format: 'json', outputs: ['console', 'file', 'cloudwatch'] },
  features: { newCheckout: true, betaFeature: false }
}

const finalConfig = loadConfig([defaultConfig, envConfig])

ベストプラクティス

1. 判別プロパティを一貫して使う
// Good: consistent _tag for all variants
type Result<E, A> =
  | { readonly _tag: 'Failure'; readonly error: E }
  | { readonly _tag: 'Success'; readonly value: A }

// Bad: inconsistent discriminant
type BadResult<E, A> =
  | { readonly type: 'error'; readonly error: E }
  | { readonly kind: 'success'; readonly value: A }
2. 不正な状態を表現できないようにする
// Bad: allows invalid combinations
interface BadOrder {
  readonly status: 'shipped' | 'pending'
  readonly trackingNumber: string | null // Only valid for shipped
}

// Good: sum type ensures valid combinations
type GoodOrder =
  | { readonly _tag: 'Pending' }
  | { readonly _tag: 'Shipped'; readonly trackingNumber: string }
3. スマートコンストラクターを使う
// Expose constructors, not raw objects
const Order = {
  pending: (): Order => ({ _tag: 'Pending' }),
  shipped: (trackingNumber: string): Order => ({ _tag: 'Shipped', trackingNumber })
}

// Don't export the type definition directly for construction
// Export only through smart constructors
4. 構造的な型クラスを優先する
import * as Eq from 'fp-ts/Eq'
import * as S from 'fp-ts/Semigroup'

// Good: use struct to build from smaller pieces
const UserEq: Eq.Eq<User> = Eq.struct({
  id: Str.Eq,
  email: Str.Eq,
  name: Str.Eq
})

// Good: compose type class instances
const ConfigSemigroup: S.Semigroup<Config> = S.struct({
  server: ServerConfigSemigroup,
  database: DatabaseConfigSemigroup
})
5. fold/match ですべてのケースを網羅する
// TypeScript will error if you miss a case
const handleStatus = (status: OrderStatus): string =>
  pipe(
    status,
    foldOrderStatus({
      Draft: () => 'Draft',
      Pending: () => 'Pending',
      Paid: () => 'Paid',
      Shipped: () => 'Shipped',
      Delivered: () => 'Delivered',
      Cancelled: () => 'Cancelled',
      Refunded: () => 'Refunded'
      // Missing a case would cause compile error
    })
  )

避けるべきアンチパターン

直和型に enum を使わない
// Bad: enums don't carry associated data
enum OrderStatus {
  Pending,
  Shipped,
  Delivered
}
// Where does tracking number go for Shipped?

// Good: discriminated unions carry data
type OrderStatus =
  | { readonly _tag: 'Pending' }
  | { readonly _tag: 'Shipped'; readonly trackingNumber: string }
  | { readonly _tag: 'Delivered'; readonly deliveredAt: Date }
型クラスを誤って組み合わせない
// Bad: using Ord when you only need Eq
const findUser = (users: User[], target: User) =>
  users.find(u => Ord.equals(UserOrd)(u, target)) // Ord.equals doesn't exist

// Good: use the right type class
const findUser = (users: User[], target: User) =>
  users.find(u => UserEq.equals(u, target))
モノイドの単位元則を忘れない
// Bad: this isn't a valid Monoid
const BadMonoid: M.Monoid<number> = {
  concat: (a, b) => a + b + 1, // Adding 1 breaks identity law
  empty: 0
}
// concat(0, 5) = 6, not 5!

// Good: respects identity laws
const GoodMonoid: M.Monoid<number> = {
  concat: (a, b) => a + b,
  empty: 0
}
// concat(0, 5) = 5 ✓

コメント