Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add whatiskadudoing/fp-ts-skills --skill "fp-ts Algebraic Data Types and Type Classes"
Install specific skill from multi-skill repository
# Description
Product types, sum types, semigroups, monoids, Eq, Ord, and building custom type class instances for domain modeling in TypeScript
# SKILL.md
name: fp-ts Algebraic Data Types and Type Classes
description: Product types, sum types, semigroups, monoids, Eq, Ord, and building custom type class instances for domain modeling in TypeScript
version: 1.0.0
author: kadu
tags:
- fp-ts
- functional-programming
- typescript
- algebraic-data-types
- type-classes
- semigroup
- monoid
- eq
- ord
- domain-modeling
fp-ts Algebraic Data Types and Type Classes
This skill covers algebraic data types (ADTs) and type classes in fp-ts for robust domain modeling in TypeScript.
Core Concepts
What are Algebraic Data Types?
Algebraic Data Types (ADTs) are composite types formed by combining other types. There are two fundamental kinds:
- Product Types: Combine types with AND (tuples, records) - "A user HAS a name AND an email"
- Sum Types: Combine types with OR (discriminated unions) - "A result IS either success OR failure"
What are Type Classes?
Type classes define behavior that can be implemented for different types. In fp-ts, they're represented as interfaces with instances for specific types:
- Eq: Equality comparison
- Ord: Ordering/comparison
- Semigroup: Combining two values of the same type
- Monoid: Semigroup with an identity element
Product Types
Product types represent values that contain ALL of their component parts simultaneously.
Tuples
Tuples are fixed-length arrays with typed positions.
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]
Records (Object Types)
Records are the most common product type in 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
}
When to Use Product Types
Use product types when:
- An entity requires ALL pieces of data to be valid
- The data naturally belongs together
- You're modeling database entities or API payloads
- Components are always present together
// 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
}
Sum Types (Discriminated Unions)
Sum types represent values that can be ONE OF several variants.
Basic Discriminated Unions
// 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]'
}
Pattern Matching with 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"
Smart Constructors for Sum Types
// 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} />
})
)
When to Use Sum Types
Use sum types when:
- A value can be in different states
- Different variants have different data shapes
- You need exhaustive handling of all cases
- Modeling state machines or workflows
// 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> }
Semigroups
A Semigroup defines how to combine two values of the same type. It must satisfy the associativity law: combine(combine(a, b), c) === combine(a, combine(b, c))
Basic Semigroup Usage
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
Custom Semigroup Instances
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)
}
Practical Example: Merging Configs
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 for Optional Values
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
})
Monoids
A Monoid is a Semigroup with an identity element (empty). The empty element satisfies: concat(empty, a) === a and concat(a, empty) === a
Basic Monoid Usage
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 // []
Custom Monoid Instances
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
Practical Example: Combining Results
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 for Statistics
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 Type Class
Eq defines equality comparison for types.
Basic Eq Usage
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
Custom Eq Instances
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)
Practical Example: Deduplication
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
Case-Insensitive String 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 Type Class
Ord extends Eq with ordering/comparison capabilities.
Basic Ord Usage
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
Custom Ord Instances
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)
Compound Ordering
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)
Practical Example: Sorting and Filtering
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)
)
Using fp-ts Type Class Instances
Getting Instances from Modules
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
Combining Type Classes
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)
)
Building Domain-Specific Type Classes
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'))
)
Practical Domain Modeling Examples
Example 1: E-commerce Order System
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)
Example 2: Configuration Management
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])
Best Practices
1. Use Discriminant Property Consistently
// 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. Make Illegal States Unrepresentable
// 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. Use Smart Constructors
// 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. Prefer Structural Type Classes
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. Keep Fold/Match Exhaustive
// 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
})
)
Anti-Patterns to Avoid
Don't Use Enums for Sum Types
// 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 }
Don't Mix Type Classes Incorrectly
// 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))
Don't Forget Identity Laws for Monoids
// 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 โ
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.