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.
typescript
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.
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: 'john@example.com',
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
typescript
// 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
typescript
// 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: 'user@example.com'
}
Pattern Matching with fold/match
typescript
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
typescript
// 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
typescript
// 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
typescript
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
typescript
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
typescript
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
typescript
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