feat: add fp-ts skills for TypeScript functional programming (#43)
Add three practical fp-ts skills: - fp-ts-pragmatic: The 80/20 of functional programming, jargon-free - fp-ts-react: Patterns for using fp-ts with React 18/19 and Next.js - fp-ts-errors: Type-safe error handling with Either and TaskEither Source: https://github.com/whatiskadudoing/fp-ts-skills Co-authored-by: kadu-maverickk <maycon.guedes@itglobers.com>
This commit is contained in:
856
skills/fp-ts-errors/SKILL.md
Normal file
856
skills/fp-ts-errors/SKILL.md
Normal file
@@ -0,0 +1,856 @@
|
||||
---
|
||||
name: fp-ts-errors
|
||||
description: Handle errors as values using fp-ts Either and TaskEither for cleaner, more predictable TypeScript code. Use when implementing error handling patterns with fp-ts.
|
||||
risk: safe
|
||||
source: https://github.com/whatiskadudoing/fp-ts-skills
|
||||
---
|
||||
|
||||
# Practical Error Handling with fp-ts
|
||||
|
||||
This skill teaches you how to handle errors without try/catch spaghetti. No academic jargon - just practical patterns for real problems.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- When you want type-safe error handling in TypeScript
|
||||
- When replacing try/catch with Either and TaskEither patterns
|
||||
- When building APIs or services that need explicit error types
|
||||
- When accumulating multiple validation errors
|
||||
|
||||
The core idea: **Errors are just data**. Instead of throwing them into the void and hoping someone catches them, return them as values that TypeScript can track.
|
||||
|
||||
---
|
||||
|
||||
## 1. Stop Throwing Everywhere
|
||||
|
||||
### The Problem with Exceptions
|
||||
|
||||
Exceptions are invisible in your types. They break the contract between functions.
|
||||
|
||||
```typescript
|
||||
// What this function signature promises:
|
||||
function getUser(id: string): User
|
||||
|
||||
// What it actually does:
|
||||
function getUser(id: string): User {
|
||||
if (!id) throw new Error('ID required')
|
||||
const user = db.find(id)
|
||||
if (!user) throw new Error('User not found')
|
||||
return user
|
||||
}
|
||||
|
||||
// The caller has no idea this can fail
|
||||
const user = getUser(id) // Might explode!
|
||||
```
|
||||
|
||||
You end up with code like this:
|
||||
|
||||
```typescript
|
||||
// MESSY: try/catch everywhere
|
||||
function processOrder(orderId: string) {
|
||||
let order
|
||||
try {
|
||||
order = getOrder(orderId)
|
||||
} catch (e) {
|
||||
console.error('Failed to get order')
|
||||
return null
|
||||
}
|
||||
|
||||
let user
|
||||
try {
|
||||
user = getUser(order.userId)
|
||||
} catch (e) {
|
||||
console.error('Failed to get user')
|
||||
return null
|
||||
}
|
||||
|
||||
let payment
|
||||
try {
|
||||
payment = chargeCard(user.cardId, order.total)
|
||||
} catch (e) {
|
||||
console.error('Payment failed')
|
||||
return null
|
||||
}
|
||||
|
||||
return { order, user, payment }
|
||||
}
|
||||
```
|
||||
|
||||
### The Solution: Return Errors as Values
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Now TypeScript KNOWS this can fail
|
||||
function getUser(id: string): E.Either<string, User> {
|
||||
if (!id) return E.left('ID required')
|
||||
const user = db.find(id)
|
||||
if (!user) return E.left('User not found')
|
||||
return E.right(user)
|
||||
}
|
||||
|
||||
// The caller is forced to handle both cases
|
||||
const result = getUser(id)
|
||||
// result is Either<string, User> - error OR success, never both
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. The Result Pattern (Either)
|
||||
|
||||
`Either<E, A>` is simple: it holds either an error (`E`) or a value (`A`).
|
||||
|
||||
- `Left` = error case
|
||||
- `Right` = success case (think "right" as in "correct")
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
|
||||
// Creating values
|
||||
const success = E.right(42) // Right(42)
|
||||
const failure = E.left('Oops') // Left('Oops')
|
||||
|
||||
// Checking what you have
|
||||
if (E.isRight(result)) {
|
||||
console.log(result.right) // The success value
|
||||
} else {
|
||||
console.log(result.left) // The error
|
||||
}
|
||||
|
||||
// Better: pattern match with fold
|
||||
const message = pipe(
|
||||
result,
|
||||
E.fold(
|
||||
(error) => `Failed: ${error}`,
|
||||
(value) => `Got: ${value}`
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Converting Throwing Code to Either
|
||||
|
||||
```typescript
|
||||
// Wrap any throwing function with tryCatch
|
||||
const parseJSON = (json: string): E.Either<Error, unknown> =>
|
||||
E.tryCatch(
|
||||
() => JSON.parse(json),
|
||||
(e) => (e instanceof Error ? e : new Error(String(e)))
|
||||
)
|
||||
|
||||
parseJSON('{"valid": true}') // Right({ valid: true })
|
||||
parseJSON('not json') // Left(SyntaxError: ...)
|
||||
|
||||
// For functions you'll reuse, use tryCatchK
|
||||
const safeParseJSON = E.tryCatchK(
|
||||
JSON.parse,
|
||||
(e) => (e instanceof Error ? e : new Error(String(e)))
|
||||
)
|
||||
```
|
||||
|
||||
### Common Either Operations
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Transform the success value
|
||||
const doubled = pipe(
|
||||
E.right(21),
|
||||
E.map(n => n * 2)
|
||||
) // Right(42)
|
||||
|
||||
// Transform the error
|
||||
const betterError = pipe(
|
||||
E.left('bad'),
|
||||
E.mapLeft(e => `Error: ${e}`)
|
||||
) // Left('Error: bad')
|
||||
|
||||
// Provide a default for errors
|
||||
const value = pipe(
|
||||
E.left('failed'),
|
||||
E.getOrElse(() => 0)
|
||||
) // 0
|
||||
|
||||
// Convert nullable to Either
|
||||
const fromNullable = E.fromNullable('not found')
|
||||
fromNullable(user) // Right(user) if exists, Left('not found') if null/undefined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Chaining Operations That Might Fail
|
||||
|
||||
The real power comes from chaining. Each step can fail, but you write it as a clean pipeline.
|
||||
|
||||
### Before: Nested Try/Catch Hell
|
||||
|
||||
```typescript
|
||||
// MESSY: Each step can fail, nested try/catch everywhere
|
||||
function processUserOrder(userId: string, productId: string): Result | null {
|
||||
let user
|
||||
try {
|
||||
user = getUser(userId)
|
||||
} catch (e) {
|
||||
logError('User fetch failed', e)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
logError('User not active')
|
||||
return null
|
||||
}
|
||||
|
||||
let product
|
||||
try {
|
||||
product = getProduct(productId)
|
||||
} catch (e) {
|
||||
logError('Product fetch failed', e)
|
||||
return null
|
||||
}
|
||||
|
||||
if (product.stock < 1) {
|
||||
logError('Out of stock')
|
||||
return null
|
||||
}
|
||||
|
||||
let order
|
||||
try {
|
||||
order = createOrder(user, product)
|
||||
} catch (e) {
|
||||
logError('Order creation failed', e)
|
||||
return null
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
```
|
||||
|
||||
### After: Clean Chain with Either
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Each function returns Either<Error, T>
|
||||
const getUser = (id: string): E.Either<string, User> => { ... }
|
||||
const getProduct = (id: string): E.Either<string, Product> => { ... }
|
||||
const createOrder = (user: User, product: Product): E.Either<string, Order> => { ... }
|
||||
|
||||
// Chain them together - first error stops the chain
|
||||
const processUserOrder = (userId: string, productId: string): E.Either<string, Order> =>
|
||||
pipe(
|
||||
getUser(userId),
|
||||
E.filterOrElse(
|
||||
user => user.isActive,
|
||||
() => 'User not active'
|
||||
),
|
||||
E.chain(user =>
|
||||
pipe(
|
||||
getProduct(productId),
|
||||
E.filterOrElse(
|
||||
product => product.stock >= 1,
|
||||
() => 'Out of stock'
|
||||
),
|
||||
E.chain(product => createOrder(user, product))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Or use Do notation for cleaner access to intermediate values
|
||||
const processUserOrder = (userId: string, productId: string): E.Either<string, Order> =>
|
||||
pipe(
|
||||
E.Do,
|
||||
E.bind('user', () => getUser(userId)),
|
||||
E.filterOrElse(
|
||||
({ user }) => user.isActive,
|
||||
() => 'User not active'
|
||||
),
|
||||
E.bind('product', () => getProduct(productId)),
|
||||
E.filterOrElse(
|
||||
({ product }) => product.stock >= 1,
|
||||
() => 'Out of stock'
|
||||
),
|
||||
E.chain(({ user, product }) => createOrder(user, product))
|
||||
)
|
||||
```
|
||||
|
||||
### Different Error Types? Use chainW
|
||||
|
||||
```typescript
|
||||
type ValidationError = { type: 'validation'; message: string }
|
||||
type DbError = { type: 'db'; message: string }
|
||||
|
||||
const validateInput = (id: string): E.Either<ValidationError, string> => { ... }
|
||||
const fetchFromDb = (id: string): E.Either<DbError, User> => { ... }
|
||||
|
||||
// chainW (W = "wider") automatically unions the error types
|
||||
const process = (id: string): E.Either<ValidationError | DbError, User> =>
|
||||
pipe(
|
||||
validateInput(id),
|
||||
E.chainW(validId => fetchFromDb(validId))
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Collecting Multiple Errors
|
||||
|
||||
Sometimes you want ALL errors, not just the first one. Form validation is the classic example.
|
||||
|
||||
### Before: Collecting Errors Manually
|
||||
|
||||
```typescript
|
||||
// MESSY: Manual error accumulation
|
||||
function validateForm(form: FormData): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!form.email) {
|
||||
errors.push('Email required')
|
||||
} else if (!form.email.includes('@')) {
|
||||
errors.push('Invalid email')
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
errors.push('Password required')
|
||||
} else if (form.password.length < 8) {
|
||||
errors.push('Password too short')
|
||||
}
|
||||
|
||||
if (!form.age) {
|
||||
errors.push('Age required')
|
||||
} else if (form.age < 18) {
|
||||
errors.push('Must be 18+')
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
```
|
||||
|
||||
### After: Validation with Error Accumulation
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import * as NEA from 'fp-ts/NonEmptyArray'
|
||||
import { sequenceS } from 'fp-ts/Apply'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Errors as a NonEmptyArray (always at least one)
|
||||
type Errors = NEA.NonEmptyArray<string>
|
||||
|
||||
// Create the applicative that accumulates errors
|
||||
const validation = E.getApplicativeValidation(NEA.getSemigroup<string>())
|
||||
|
||||
// Validators that return Either<Errors, T>
|
||||
const validateEmail = (email: string): E.Either<Errors, string> =>
|
||||
!email ? E.left(NEA.of('Email required'))
|
||||
: !email.includes('@') ? E.left(NEA.of('Invalid email'))
|
||||
: E.right(email)
|
||||
|
||||
const validatePassword = (password: string): E.Either<Errors, string> =>
|
||||
!password ? E.left(NEA.of('Password required'))
|
||||
: password.length < 8 ? E.left(NEA.of('Password too short'))
|
||||
: E.right(password)
|
||||
|
||||
const validateAge = (age: number | undefined): E.Either<Errors, number> =>
|
||||
age === undefined ? E.left(NEA.of('Age required'))
|
||||
: age < 18 ? E.left(NEA.of('Must be 18+'))
|
||||
: E.right(age)
|
||||
|
||||
// Combine all validations - collects ALL errors
|
||||
const validateForm = (form: FormData) =>
|
||||
sequenceS(validation)({
|
||||
email: validateEmail(form.email),
|
||||
password: validatePassword(form.password),
|
||||
age: validateAge(form.age)
|
||||
})
|
||||
|
||||
// Usage
|
||||
validateForm({ email: '', password: '123', age: 15 })
|
||||
// Left(['Email required', 'Password too short', 'Must be 18+'])
|
||||
|
||||
validateForm({ email: 'a@b.com', password: 'longpassword', age: 25 })
|
||||
// Right({ email: 'a@b.com', password: 'longpassword', age: 25 })
|
||||
```
|
||||
|
||||
### Field-Level Errors for Forms
|
||||
|
||||
```typescript
|
||||
interface FieldError {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type FormErrors = NEA.NonEmptyArray<FieldError>
|
||||
|
||||
const fieldError = (field: string, message: string): FormErrors =>
|
||||
NEA.of({ field, message })
|
||||
|
||||
const formValidation = E.getApplicativeValidation(NEA.getSemigroup<FieldError>())
|
||||
|
||||
// Now errors know which field they belong to
|
||||
const validateEmail = (email: string): E.Either<FormErrors, string> =>
|
||||
!email ? E.left(fieldError('email', 'Required'))
|
||||
: !email.includes('@') ? E.left(fieldError('email', 'Invalid format'))
|
||||
: E.right(email)
|
||||
|
||||
// Easy to display in UI
|
||||
const getFieldError = (errors: FormErrors, field: string): string | undefined =>
|
||||
errors.find(e => e.field === field)?.message
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Async Operations (TaskEither)
|
||||
|
||||
For async operations that can fail, use `TaskEither`. It's like `Either` but for promises.
|
||||
|
||||
- `TaskEither<E, A>` = a function that returns `Promise<Either<E, A>>`
|
||||
- Lazy: nothing runs until you execute it
|
||||
|
||||
```typescript
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Wrap any async operation
|
||||
const fetchUser = (id: string): TE.TaskEither<Error, User> =>
|
||||
TE.tryCatch(
|
||||
() => fetch(`/api/users/${id}`).then(r => r.json()),
|
||||
(e) => (e instanceof Error ? e : new Error(String(e)))
|
||||
)
|
||||
|
||||
// Chain async operations - just like Either
|
||||
const getUserPosts = (userId: string): TE.TaskEither<Error, Post[]> =>
|
||||
pipe(
|
||||
fetchUser(userId),
|
||||
TE.chain(user => fetchPosts(user.id))
|
||||
)
|
||||
|
||||
// Execute when ready
|
||||
const result = await getUserPosts('123')() // Returns Either<Error, Post[]>
|
||||
```
|
||||
|
||||
### Before: Promise Chain with Error Handling
|
||||
|
||||
```typescript
|
||||
// MESSY: try/catch mixed with promise chains
|
||||
async function loadDashboard(userId: string) {
|
||||
try {
|
||||
const user = await fetchUser(userId)
|
||||
if (!user) throw new Error('User not found')
|
||||
|
||||
let posts, notifications, settings
|
||||
try {
|
||||
[posts, notifications, settings] = await Promise.all([
|
||||
fetchPosts(user.id),
|
||||
fetchNotifications(user.id),
|
||||
fetchSettings(user.id)
|
||||
])
|
||||
} catch (e) {
|
||||
// Which one failed? Who knows!
|
||||
console.error('Failed to load data', e)
|
||||
return null
|
||||
}
|
||||
|
||||
return { user, posts, notifications, settings }
|
||||
} catch (e) {
|
||||
console.error('Failed to load user', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After: Clean TaskEither Pipeline
|
||||
|
||||
```typescript
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
import { sequenceS } from 'fp-ts/Apply'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
const loadDashboard = (userId: string) =>
|
||||
pipe(
|
||||
fetchUser(userId),
|
||||
TE.chain(user =>
|
||||
pipe(
|
||||
// Parallel fetch with sequenceS
|
||||
sequenceS(TE.ApplyPar)({
|
||||
posts: fetchPosts(user.id),
|
||||
notifications: fetchNotifications(user.id),
|
||||
settings: fetchSettings(user.id)
|
||||
}),
|
||||
TE.map(data => ({ user, ...data }))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Execute and handle both cases
|
||||
pipe(
|
||||
loadDashboard('123'),
|
||||
TE.fold(
|
||||
(error) => T.of(renderError(error)),
|
||||
(data) => T.of(renderDashboard(data))
|
||||
)
|
||||
)()
|
||||
```
|
||||
|
||||
### Retry Failed Operations
|
||||
|
||||
```typescript
|
||||
import * as T from 'fp-ts/Task'
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
const retry = <E, A>(
|
||||
task: TE.TaskEither<E, A>,
|
||||
attempts: number,
|
||||
delayMs: number
|
||||
): TE.TaskEither<E, A> =>
|
||||
pipe(
|
||||
task,
|
||||
TE.orElse((error) =>
|
||||
attempts > 1
|
||||
? pipe(
|
||||
T.delay(delayMs)(T.of(undefined)),
|
||||
T.chain(() => retry(task, attempts - 1, delayMs * 2))
|
||||
)
|
||||
: TE.left(error)
|
||||
)
|
||||
)
|
||||
|
||||
// Retry up to 3 times with exponential backoff
|
||||
const fetchWithRetry = retry(fetchUser('123'), 3, 1000)
|
||||
```
|
||||
|
||||
### Fallback to Alternative
|
||||
|
||||
```typescript
|
||||
// Try cache first, fall back to API
|
||||
const getUserData = (id: string) =>
|
||||
pipe(
|
||||
fetchFromCache(id),
|
||||
TE.orElse(() => fetchFromApi(id)),
|
||||
TE.orElse(() => TE.right(defaultUser)) // Last resort default
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Converting Between Patterns
|
||||
|
||||
Real codebases have throwing functions, nullable values, and promises. Here's how to work with them.
|
||||
|
||||
### From Nullable to Either
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import * as O from 'fp-ts/Option'
|
||||
|
||||
// Direct conversion
|
||||
const user = users.find(u => u.id === id) // User | undefined
|
||||
const result = E.fromNullable('User not found')(user)
|
||||
|
||||
// From Option
|
||||
const maybeUser: O.Option<User> = O.fromNullable(user)
|
||||
const eitherUser = pipe(
|
||||
maybeUser,
|
||||
E.fromOption(() => 'User not found')
|
||||
)
|
||||
```
|
||||
|
||||
### From Throwing Function to Either
|
||||
|
||||
```typescript
|
||||
// Wrap at the boundary
|
||||
const safeParse = <T>(schema: ZodSchema<T>) => (data: unknown): E.Either<ZodError, T> =>
|
||||
E.tryCatch(
|
||||
() => schema.parse(data),
|
||||
(e) => e as ZodError
|
||||
)
|
||||
|
||||
// Use throughout your code
|
||||
const parseUser = safeParse(UserSchema)
|
||||
const result = parseUser(rawData) // Either<ZodError, User>
|
||||
```
|
||||
|
||||
### From Promise to TaskEither
|
||||
|
||||
```typescript
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
|
||||
// Wrap external async functions
|
||||
const fetchJson = <T>(url: string): TE.TaskEither<Error, T> =>
|
||||
TE.tryCatch(
|
||||
() => fetch(url).then(r => r.json()),
|
||||
(e) => new Error(`Fetch failed: ${e}`)
|
||||
)
|
||||
|
||||
// Wrap axios, prisma, any async library
|
||||
const getUserFromDb = (id: string): TE.TaskEither<DbError, User> =>
|
||||
TE.tryCatch(
|
||||
() => prisma.user.findUniqueOrThrow({ where: { id } }),
|
||||
(e) => ({ code: 'DB_ERROR', cause: e })
|
||||
)
|
||||
```
|
||||
|
||||
### Back to Promise (Escape Hatch)
|
||||
|
||||
Sometimes you need a plain Promise for external APIs.
|
||||
|
||||
```typescript
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
import * as E from 'fp-ts/Either'
|
||||
|
||||
const myTaskEither: TE.TaskEither<Error, User> = fetchUser('123')
|
||||
|
||||
// Option 1: Get the Either (preserves both cases)
|
||||
const either: E.Either<Error, User> = await myTaskEither()
|
||||
|
||||
// Option 2: Throw on error (for legacy code)
|
||||
const toThrowingPromise = <E, A>(te: TE.TaskEither<E, A>): Promise<A> =>
|
||||
te().then(E.fold(
|
||||
(error) => Promise.reject(error),
|
||||
(value) => Promise.resolve(value)
|
||||
))
|
||||
|
||||
const user = await toThrowingPromise(fetchUser('123')) // Throws if Left
|
||||
|
||||
// Option 3: Default on error
|
||||
const user = await pipe(
|
||||
fetchUser('123'),
|
||||
TE.getOrElse(() => T.of(defaultUser))
|
||||
)()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real Scenarios
|
||||
|
||||
### Parse User Input Safely
|
||||
|
||||
```typescript
|
||||
interface ParsedInput {
|
||||
id: number
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const parseInput = (raw: unknown): E.Either<string, ParsedInput> =>
|
||||
pipe(
|
||||
E.Do,
|
||||
E.bind('obj', () =>
|
||||
typeof raw === 'object' && raw !== null
|
||||
? E.right(raw as Record<string, unknown>)
|
||||
: E.left('Input must be an object')
|
||||
),
|
||||
E.bind('id', ({ obj }) =>
|
||||
typeof obj.id === 'number'
|
||||
? E.right(obj.id)
|
||||
: E.left('id must be a number')
|
||||
),
|
||||
E.bind('name', ({ obj }) =>
|
||||
typeof obj.name === 'string' && obj.name.length > 0
|
||||
? E.right(obj.name)
|
||||
: E.left('name must be a non-empty string')
|
||||
),
|
||||
E.bind('tags', ({ obj }) =>
|
||||
Array.isArray(obj.tags) && obj.tags.every(t => typeof t === 'string')
|
||||
? E.right(obj.tags as string[])
|
||||
: E.left('tags must be an array of strings')
|
||||
),
|
||||
E.map(({ id, name, tags }) => ({ id, name, tags }))
|
||||
)
|
||||
|
||||
// Usage
|
||||
parseInput({ id: 1, name: 'test', tags: ['a', 'b'] })
|
||||
// Right({ id: 1, name: 'test', tags: ['a', 'b'] })
|
||||
|
||||
parseInput({ id: 'wrong', name: '', tags: null })
|
||||
// Left('id must be a number')
|
||||
```
|
||||
|
||||
### API Call with Full Error Handling
|
||||
|
||||
```typescript
|
||||
interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
const createApiError = (message: string, code = 'UNKNOWN', status?: number): ApiError =>
|
||||
({ code, message, status })
|
||||
|
||||
const fetchWithErrorHandling = <T>(url: string): TE.TaskEither<ApiError, T> =>
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() => fetch(url),
|
||||
() => createApiError('Network error', 'NETWORK')
|
||||
),
|
||||
TE.chain(response =>
|
||||
response.ok
|
||||
? TE.tryCatch(
|
||||
() => response.json() as Promise<T>,
|
||||
() => createApiError('Invalid JSON', 'PARSE')
|
||||
)
|
||||
: TE.left(createApiError(
|
||||
`HTTP ${response.status}`,
|
||||
response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
|
||||
response.status
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
// Usage with pattern matching on error codes
|
||||
const handleUserFetch = (userId: string) =>
|
||||
pipe(
|
||||
fetchWithErrorHandling<User>(`/api/users/${userId}`),
|
||||
TE.fold(
|
||||
(error) => {
|
||||
switch (error.code) {
|
||||
case 'NOT_FOUND': return T.of(showNotFoundPage())
|
||||
case 'NETWORK': return T.of(showOfflineMessage())
|
||||
default: return T.of(showGenericError(error.message))
|
||||
}
|
||||
},
|
||||
(user) => T.of(showUserProfile(user))
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Process List Where Some Items Might Fail
|
||||
|
||||
```typescript
|
||||
import * as A from 'fp-ts/Array'
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
interface ProcessResult<T> {
|
||||
successes: T[]
|
||||
failures: Array<{ item: unknown; error: string }>
|
||||
}
|
||||
|
||||
// Process all, collect successes and failures separately
|
||||
const processAllCollectErrors = <T, R>(
|
||||
items: T[],
|
||||
process: (item: T) => E.Either<string, R>
|
||||
): ProcessResult<R> => {
|
||||
const results = items.map((item, index) =>
|
||||
pipe(
|
||||
process(item),
|
||||
E.mapLeft(error => ({ item, error, index }))
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
successes: pipe(results, A.filterMap(E.toOption)),
|
||||
failures: pipe(
|
||||
results,
|
||||
A.filterMap(r => E.isLeft(r) ? O.some(r.left) : O.none)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const parseNumbers = (inputs: string[]) =>
|
||||
processAllCollectErrors(inputs, input => {
|
||||
const n = parseInt(input, 10)
|
||||
return isNaN(n) ? E.left(`Invalid number: ${input}`) : E.right(n)
|
||||
})
|
||||
|
||||
parseNumbers(['1', 'abc', '3', 'def'])
|
||||
// {
|
||||
// successes: [1, 3],
|
||||
// failures: [
|
||||
// { item: 'abc', error: 'Invalid number: abc', index: 1 },
|
||||
// { item: 'def', error: 'Invalid number: def', index: 3 }
|
||||
// ]
|
||||
// }
|
||||
```
|
||||
|
||||
### Bulk Operations with Partial Success
|
||||
|
||||
```typescript
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
import * as T from 'fp-ts/Task'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
interface BulkResult<T> {
|
||||
succeeded: T[]
|
||||
failed: Array<{ id: string; error: string }>
|
||||
}
|
||||
|
||||
const bulkProcess = <T>(
|
||||
ids: string[],
|
||||
process: (id: string) => TE.TaskEither<string, T>
|
||||
): T.Task<BulkResult<T>> =>
|
||||
pipe(
|
||||
ids,
|
||||
A.map(id =>
|
||||
pipe(
|
||||
process(id),
|
||||
TE.fold(
|
||||
(error) => T.of({ type: 'failed' as const, id, error }),
|
||||
(result) => T.of({ type: 'succeeded' as const, result })
|
||||
)
|
||||
)
|
||||
),
|
||||
T.sequenceArray,
|
||||
T.map(results => ({
|
||||
succeeded: results
|
||||
.filter((r): r is { type: 'succeeded'; result: T } => r.type === 'succeeded')
|
||||
.map(r => r.result),
|
||||
failed: results
|
||||
.filter((r): r is { type: 'failed'; id: string; error: string } => r.type === 'failed')
|
||||
.map(({ id, error }) => ({ id, error }))
|
||||
}))
|
||||
)
|
||||
|
||||
// Usage
|
||||
const deleteUsers = (userIds: string[]) =>
|
||||
bulkProcess(userIds, id =>
|
||||
pipe(
|
||||
deleteUser(id),
|
||||
TE.mapLeft(e => e.message)
|
||||
)
|
||||
)
|
||||
|
||||
// All operations run, you get a report of what worked and what didn't
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Use When | Example |
|
||||
|---------|----------|---------|
|
||||
| `E.right(value)` | Creating a success | `E.right(42)` |
|
||||
| `E.left(error)` | Creating a failure | `E.left('not found')` |
|
||||
| `E.tryCatch(fn, onError)` | Wrapping throwing code | `E.tryCatch(() => JSON.parse(s), toError)` |
|
||||
| `E.fromNullable(error)` | Converting nullable | `E.fromNullable('missing')(maybeValue)` |
|
||||
| `E.map(fn)` | Transform success | `pipe(result, E.map(x => x * 2))` |
|
||||
| `E.mapLeft(fn)` | Transform error | `pipe(result, E.mapLeft(addContext))` |
|
||||
| `E.chain(fn)` | Chain operations | `pipe(getA(), E.chain(a => getB(a.id)))` |
|
||||
| `E.chainW(fn)` | Chain with different error type | `pipe(validate(), E.chainW(save))` |
|
||||
| `E.fold(onError, onSuccess)` | Handle both cases | `E.fold(showError, showData)` |
|
||||
| `E.getOrElse(onError)` | Extract with default | `E.getOrElse(() => 0)` |
|
||||
| `E.filterOrElse(pred, onFalse)` | Validate with error | `E.filterOrElse(x => x > 0, () => 'must be positive')` |
|
||||
| `sequenceS(validation)({...})` | Collect all errors | Form validation |
|
||||
|
||||
### TaskEither Equivalents
|
||||
|
||||
All Either operations have TaskEither equivalents:
|
||||
- `TE.right`, `TE.left`, `TE.tryCatch`
|
||||
- `TE.map`, `TE.mapLeft`, `TE.chain`, `TE.chainW`
|
||||
- `TE.fold`, `TE.getOrElse`, `TE.filterOrElse`
|
||||
- `TE.orElse` for fallbacks
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
1. **Return errors as values** - Use Either/TaskEither instead of throwing
|
||||
2. **Chain with confidence** - `chain` stops at first error automatically
|
||||
3. **Collect all errors when needed** - Use validation applicative for forms
|
||||
4. **Wrap at boundaries** - Convert throwing/Promise code at the edges
|
||||
5. **Match at the end** - Use `fold` to handle both cases when you're ready to act
|
||||
|
||||
The payoff: TypeScript tracks your errors, no more forgotten try/catch, clear control flow, and composable error handling.
|
||||
598
skills/fp-ts-pragmatic/SKILL.md
Normal file
598
skills/fp-ts-pragmatic/SKILL.md
Normal file
@@ -0,0 +1,598 @@
|
||||
---
|
||||
name: fp-ts-pragmatic
|
||||
description: A practical, jargon-free guide to fp-ts functional programming - the 80/20 approach that gets results without the academic overhead. Use when writing TypeScript with fp-ts library.
|
||||
risk: safe
|
||||
source: https://github.com/whatiskadudoing/fp-ts-skills
|
||||
---
|
||||
|
||||
# Pragmatic Functional Programming
|
||||
|
||||
**Read this first.** This guide cuts through the academic jargon and shows you what actually matters. No category theory. No abstract nonsense. Just patterns that make your code better.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- When starting with fp-ts and need practical guidance
|
||||
- When writing TypeScript code that handles nullable values, errors, or async operations
|
||||
- When you want cleaner, more maintainable functional code without the academic overhead
|
||||
- When refactoring imperative code to functional style
|
||||
|
||||
## The Golden Rule
|
||||
|
||||
> **If functional programming makes your code harder to read, don't use it.**
|
||||
|
||||
FP is a tool, not a religion. Use it when it helps. Skip it when it doesn't.
|
||||
|
||||
---
|
||||
|
||||
## The 80/20 of FP
|
||||
|
||||
These five patterns give you most of the benefits. Master these before exploring anything else.
|
||||
|
||||
### 1. Pipe: Chain Operations Clearly
|
||||
|
||||
Instead of nesting function calls or creating intermediate variables, chain operations in reading order.
|
||||
|
||||
```typescript
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
**When to use pipe:**
|
||||
- 3+ transformations on the same data
|
||||
- You find yourself naming throwaway variables
|
||||
- Logic reads better top-to-bottom
|
||||
|
||||
**When to skip pipe:**
|
||||
- Just 1-2 operations (direct call is fine)
|
||||
- The operations don't naturally chain
|
||||
|
||||
### 2. Option: Handle Missing Values Without null Checks
|
||||
|
||||
Stop writing `if (x !== null && x !== undefined)` everywhere.
|
||||
|
||||
```typescript
|
||||
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')
|
||||
)
|
||||
```
|
||||
|
||||
**Plain language translation:**
|
||||
- `O.fromNullable(x)` = "wrap this value, treating null/undefined as 'nothing'"
|
||||
- `O.flatMap(fn)` = "if we have something, apply this function"
|
||||
- `O.getOrElse(() => default)` = "unwrap, or use this default if nothing"
|
||||
|
||||
### 3. Either: Make Errors Explicit
|
||||
|
||||
Stop throwing exceptions for expected failures. Return errors as values.
|
||||
|
||||
```typescript
|
||||
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}`)
|
||||
}
|
||||
```
|
||||
|
||||
**Plain language translation:**
|
||||
- `E.right(value)` = "success with this value"
|
||||
- `E.left(error)` = "failure with this error"
|
||||
- `E.isRight(x)` = "did it succeed?"
|
||||
|
||||
### 4. Map: Transform Without Unpacking
|
||||
|
||||
Transform values inside containers without extracting them first.
|
||||
|
||||
```typescript
|
||||
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: Chain Operations That Might Fail
|
||||
|
||||
When each step might fail, chain them together.
|
||||
|
||||
```typescript
|
||||
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('user@example.com')
|
||||
// Any failure: Left('specific error message')
|
||||
```
|
||||
|
||||
**Plain language:** `flatMap` means "if this succeeded, try the next thing"
|
||||
|
||||
---
|
||||
|
||||
## When NOT to Use FP
|
||||
|
||||
Functional programming is not always the answer. Here's when to keep it simple.
|
||||
|
||||
### Simple Null Checks
|
||||
|
||||
```typescript
|
||||
// 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')
|
||||
)
|
||||
```
|
||||
|
||||
### Simple Loops
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
)
|
||||
```
|
||||
|
||||
### Performance-Critical Code
|
||||
|
||||
```typescript
|
||||
// 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))
|
||||
```
|
||||
|
||||
### When Your Team Doesn't Know FP
|
||||
|
||||
If you're the only one who can read the code, it's not good code.
|
||||
|
||||
```typescript
|
||||
// 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))
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins: Easy Changes That Improve Code Today
|
||||
|
||||
### 1. Replace Nested Ternaries with pipe + fold
|
||||
|
||||
```typescript
|
||||
// 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. Replace try-catch with tryCatch
|
||||
|
||||
```typescript
|
||||
// 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. Replace undefined Returns with Option
|
||||
|
||||
```typescript
|
||||
// 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. Replace Error Strings with Typed Errors
|
||||
|
||||
```typescript
|
||||
// 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. Use const Assertions for Error Types
|
||||
|
||||
```typescript
|
||||
// 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(', ')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Refactors: Before and After
|
||||
|
||||
### Callback Hell to Pipe
|
||||
|
||||
```typescript
|
||||
// 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))
|
||||
)
|
||||
```
|
||||
|
||||
### Multiple null Checks to Option Chain
|
||||
|
||||
```typescript
|
||||
// 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)
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Validation with Multiple Checks
|
||||
|
||||
```typescript
|
||||
// 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 Chain to TaskEither
|
||||
|
||||
```typescript
|
||||
// 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)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Readability Rule
|
||||
|
||||
Before using any FP pattern, ask: **"Would a junior developer understand this?"**
|
||||
|
||||
### Too Clever (Avoid)
|
||||
|
||||
```typescript
|
||||
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))
|
||||
)
|
||||
```
|
||||
|
||||
### Just Right (Prefer)
|
||||
|
||||
```typescript
|
||||
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
|
||||
```
|
||||
|
||||
### The Middle Ground (Often Best)
|
||||
|
||||
```typescript
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cheat Sheet
|
||||
|
||||
| What you want | Plain language | fp-ts |
|
||||
|--------------|----------------|-------|
|
||||
| Handle null/undefined | "Wrap this nullable" | `O.fromNullable(x)` |
|
||||
| Default for missing | "Use this if nothing" | `O.getOrElse(() => default)` |
|
||||
| Transform if present | "If something, change it" | `O.map(fn)` |
|
||||
| Chain nullable operations | "If something, try this" | `O.flatMap(fn)` |
|
||||
| Return success | "Worked, here's the value" | `E.right(value)` |
|
||||
| Return failure | "Failed, here's why" | `E.left(error)` |
|
||||
| Wrap throwing function | "Try this, catch errors" | `E.tryCatch(fn, onError)` |
|
||||
| Handle both cases | "Do this for error, that for success" | `E.fold(onLeft, onRight)` |
|
||||
| Chain operations | "Then do this, then that" | `pipe(x, fn1, fn2, fn3)` |
|
||||
|
||||
---
|
||||
|
||||
## When to Level Up
|
||||
|
||||
Once comfortable with these patterns, explore:
|
||||
|
||||
1. **TaskEither** - Async operations that can fail (replaces Promise + try/catch)
|
||||
2. **Validation** - Collect ALL errors instead of stopping at first
|
||||
3. **Reader** - Dependency injection without classes
|
||||
4. **Do notation** - Cleaner syntax for multiple bindings
|
||||
|
||||
But don't rush. The basics here will handle 80% of real-world scenarios. Get comfortable with these before adding more tools to your belt.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
1. **Use pipe** for 3+ operations
|
||||
2. **Use Option** for nullable chains
|
||||
3. **Use Either** for operations that can fail
|
||||
4. **Use map** to transform wrapped values
|
||||
5. **Use flatMap** to chain operations that might fail
|
||||
6. **Skip FP** when it hurts readability
|
||||
7. **Keep it simple** - if your team can't read it, it's not good code
|
||||
796
skills/fp-ts-react/SKILL.md
Normal file
796
skills/fp-ts-react/SKILL.md
Normal file
@@ -0,0 +1,796 @@
|
||||
---
|
||||
name: fp-ts-react
|
||||
description: Practical patterns for using fp-ts with React - hooks, state, forms, data fetching. Use when building React apps with functional programming patterns. Works with React 18/19, Next.js 14/15.
|
||||
risk: safe
|
||||
source: https://github.com/whatiskadudoing/fp-ts-skills
|
||||
---
|
||||
|
||||
# Functional Programming in React
|
||||
|
||||
Practical patterns for React apps. No jargon, just code that works.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- When building React apps with fp-ts for type-safe state management
|
||||
- When handling loading/error/success states in data fetching
|
||||
- When implementing form validation with error accumulation
|
||||
- When using React 18/19 or Next.js 14/15 with functional patterns
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Use When |
|
||||
|---------|----------|
|
||||
| `Option` | Value might be missing (user not loaded yet) |
|
||||
| `Either` | Operation might fail (form validation) |
|
||||
| `TaskEither` | Async operation might fail (API calls) |
|
||||
| `RemoteData` | Need to show loading/error/success states |
|
||||
| `pipe` | Chaining multiple transformations |
|
||||
|
||||
---
|
||||
|
||||
## 1. State with Option (Maybe It's There, Maybe Not)
|
||||
|
||||
Use `Option` instead of `null | undefined` for clearer intent.
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react'
|
||||
import * as O from 'fp-ts/Option'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
function UserProfile() {
|
||||
// Option says "this might not exist yet"
|
||||
const [user, setUser] = useState<O.Option<User>>(O.none)
|
||||
|
||||
const handleLogin = (userData: User) => {
|
||||
setUser(O.some(userData))
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
setUser(O.none)
|
||||
}
|
||||
|
||||
return pipe(
|
||||
user,
|
||||
O.match(
|
||||
// When there's no user
|
||||
() => <button onClick={() => handleLogin({ id: '1', name: 'Alice', email: 'alice@example.com' })}>
|
||||
Log In
|
||||
</button>,
|
||||
// When there's a user
|
||||
(u) => (
|
||||
<div>
|
||||
<p>Welcome, {u.name}!</p>
|
||||
<button onClick={handleLogout}>Log Out</button>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Chaining Optional Values
|
||||
|
||||
```typescript
|
||||
import * as O from 'fp-ts/Option'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
interface Profile {
|
||||
user: O.Option<{
|
||||
name: string
|
||||
settings: O.Option<{
|
||||
theme: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
function getTheme(profile: Profile): string {
|
||||
return pipe(
|
||||
profile.user,
|
||||
O.flatMap(u => u.settings),
|
||||
O.map(s => s.theme),
|
||||
O.getOrElse(() => 'light') // default
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Form Validation with Either
|
||||
|
||||
Either is perfect for validation: `Left` = errors, `Right` = valid data.
|
||||
|
||||
### Simple Form Validation
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import * as A from 'fp-ts/Array'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Validation functions return Either<ErrorMessage, ValidValue>
|
||||
const validateEmail = (email: string): E.Either<string, string> =>
|
||||
email.includes('@')
|
||||
? E.right(email)
|
||||
: E.left('Invalid email address')
|
||||
|
||||
const validatePassword = (password: string): E.Either<string, string> =>
|
||||
password.length >= 8
|
||||
? E.right(password)
|
||||
: E.left('Password must be at least 8 characters')
|
||||
|
||||
const validateName = (name: string): E.Either<string, string> =>
|
||||
name.trim().length > 0
|
||||
? E.right(name.trim())
|
||||
: E.left('Name is required')
|
||||
```
|
||||
|
||||
### Collecting All Errors (Not Just First One)
|
||||
|
||||
```typescript
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { sequenceS } from 'fp-ts/Apply'
|
||||
import { getSemigroup } from 'fp-ts/NonEmptyArray'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// This collects ALL errors, not just the first one
|
||||
const validateAll = sequenceS(E.getApplicativeValidation(getSemigroup<string>()))
|
||||
|
||||
interface SignupForm {
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface ValidatedForm {
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
function validateForm(form: SignupForm): E.Either<string[], ValidatedForm> {
|
||||
return pipe(
|
||||
validateAll({
|
||||
name: pipe(validateName(form.name), E.mapLeft(e => [e])),
|
||||
email: pipe(validateEmail(form.email), E.mapLeft(e => [e])),
|
||||
password: pipe(validatePassword(form.password), E.mapLeft(e => [e])),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Usage in component
|
||||
function SignupForm() {
|
||||
const [form, setForm] = useState({ name: '', email: '', password: '' })
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
|
||||
const handleSubmit = () => {
|
||||
pipe(
|
||||
validateForm(form),
|
||||
E.match(
|
||||
(errs) => setErrors(errs), // Show all errors
|
||||
(valid) => {
|
||||
setErrors([])
|
||||
submitToServer(valid) // Submit valid data
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={e => { e.preventDefault(); handleSubmit() }}>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
value={form.email}
|
||||
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="Password"
|
||||
/>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul style={{ color: 'red' }}>
|
||||
{errors.map((err, i) => <li key={i}>{err}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button type="submit">Sign Up</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Field-Level Errors (Better UX)
|
||||
|
||||
```typescript
|
||||
type FieldErrors = Partial<Record<keyof SignupForm, string>>
|
||||
|
||||
function validateFormWithFieldErrors(form: SignupForm): E.Either<FieldErrors, ValidatedForm> {
|
||||
const errors: FieldErrors = {}
|
||||
|
||||
pipe(validateName(form.name), E.mapLeft(e => { errors.name = e }))
|
||||
pipe(validateEmail(form.email), E.mapLeft(e => { errors.email = e }))
|
||||
pipe(validatePassword(form.password), E.mapLeft(e => { errors.password = e }))
|
||||
|
||||
return Object.keys(errors).length > 0
|
||||
? E.left(errors)
|
||||
: E.right({ name: form.name.trim(), email: form.email, password: form.password })
|
||||
}
|
||||
|
||||
// In component
|
||||
{errors.email && <span className="error">{errors.email}</span>}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Fetching with TaskEither
|
||||
|
||||
TaskEither = async operation that might fail. Perfect for API calls.
|
||||
|
||||
### Basic Fetch Hook
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as TE from 'fp-ts/TaskEither'
|
||||
import * as E from 'fp-ts/Either'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
|
||||
// Wrap fetch in TaskEither
|
||||
const fetchJson = <T>(url: string): TE.TaskEither<Error, T> =>
|
||||
TE.tryCatch(
|
||||
async () => {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
},
|
||||
(err) => err instanceof Error ? err : new Error(String(err))
|
||||
)
|
||||
|
||||
// Custom hook
|
||||
function useFetch<T>(url: string) {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
pipe(
|
||||
fetchJson<T>(url),
|
||||
TE.match(
|
||||
(err) => {
|
||||
setError(err)
|
||||
setLoading(false)
|
||||
},
|
||||
(result) => {
|
||||
setData(result)
|
||||
setLoading(false)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}, [url])
|
||||
|
||||
return { data, error, loading }
|
||||
}
|
||||
|
||||
// Usage
|
||||
function UserList() {
|
||||
const { data, error, loading } = useFetch<User[]>('/api/users')
|
||||
|
||||
if (loading) return <div>Loading...</div>
|
||||
if (error) return <div>Error: {error.message}</div>
|
||||
return (
|
||||
<ul>
|
||||
{data?.map(user => <li key={user.id}>{user.name}</li>)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Chaining API Calls
|
||||
|
||||
```typescript
|
||||
// Fetch user, then fetch their posts
|
||||
const fetchUserWithPosts = (userId: string) => pipe(
|
||||
fetchJson<User>(`/api/users/${userId}`),
|
||||
TE.flatMap(user => pipe(
|
||||
fetchJson<Post[]>(`/api/users/${userId}/posts`),
|
||||
TE.map(posts => ({ ...user, posts }))
|
||||
))
|
||||
)
|
||||
```
|
||||
|
||||
### Parallel API Calls
|
||||
|
||||
```typescript
|
||||
import { sequenceT } from 'fp-ts/Apply'
|
||||
|
||||
// Fetch multiple things at once
|
||||
const fetchDashboardData = () => pipe(
|
||||
sequenceT(TE.ApplyPar)(
|
||||
fetchJson<User>('/api/user'),
|
||||
fetchJson<Stats>('/api/stats'),
|
||||
fetchJson<Notifications[]>('/api/notifications')
|
||||
),
|
||||
TE.map(([user, stats, notifications]) => ({
|
||||
user,
|
||||
stats,
|
||||
notifications
|
||||
}))
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. RemoteData Pattern (The Right Way to Handle Async State)
|
||||
|
||||
Stop using `{ data, loading, error }` booleans. Use a proper state machine.
|
||||
|
||||
### The Pattern
|
||||
|
||||
```typescript
|
||||
// RemoteData has exactly 4 states - no impossible combinations
|
||||
type RemoteData<E, A> =
|
||||
| { _tag: 'NotAsked' } // Haven't started yet
|
||||
| { _tag: 'Loading' } // In progress
|
||||
| { _tag: 'Failure'; error: E } // Failed
|
||||
| { _tag: 'Success'; data: A } // Got it!
|
||||
|
||||
// Constructors
|
||||
const notAsked = <E, A>(): RemoteData<E, A> => ({ _tag: 'NotAsked' })
|
||||
const loading = <E, A>(): RemoteData<E, A> => ({ _tag: 'Loading' })
|
||||
const failure = <E, A>(error: E): RemoteData<E, A> => ({ _tag: 'Failure', error })
|
||||
const success = <E, A>(data: A): RemoteData<E, A> => ({ _tag: 'Success', data })
|
||||
|
||||
// Pattern match all states
|
||||
function fold<E, A, R>(
|
||||
rd: RemoteData<E, A>,
|
||||
onNotAsked: () => R,
|
||||
onLoading: () => R,
|
||||
onFailure: (e: E) => R,
|
||||
onSuccess: (a: A) => R
|
||||
): R {
|
||||
switch (rd._tag) {
|
||||
case 'NotAsked': return onNotAsked()
|
||||
case 'Loading': return onLoading()
|
||||
case 'Failure': return onFailure(rd.error)
|
||||
case 'Success': return onSuccess(rd.data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook with RemoteData
|
||||
|
||||
```typescript
|
||||
function useRemoteData<T>(fetchFn: () => Promise<T>) {
|
||||
const [state, setState] = useState<RemoteData<Error, T>>(notAsked())
|
||||
|
||||
const execute = async () => {
|
||||
setState(loading())
|
||||
try {
|
||||
const data = await fetchFn()
|
||||
setState(success(data))
|
||||
} catch (err) {
|
||||
setState(failure(err instanceof Error ? err : new Error(String(err))))
|
||||
}
|
||||
}
|
||||
|
||||
return { state, execute }
|
||||
}
|
||||
|
||||
// Usage
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const { state, execute } = useRemoteData(() =>
|
||||
fetch(`/api/users/${userId}`).then(r => r.json())
|
||||
)
|
||||
|
||||
useEffect(() => { execute() }, [userId])
|
||||
|
||||
return fold(
|
||||
state,
|
||||
() => <button onClick={execute}>Load User</button>,
|
||||
() => <Spinner />,
|
||||
(err) => <ErrorMessage message={err.message} onRetry={execute} />,
|
||||
(user) => <UserCard user={user} />
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Why RemoteData Beats Booleans
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Impossible states are possible
|
||||
interface BadState {
|
||||
data: User | null
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
// Can have: { data: user, loading: true, error: someError } - what does that mean?!
|
||||
|
||||
// ✅ GOOD: Only valid states exist
|
||||
type GoodState = RemoteData<Error, User>
|
||||
// Can only be: NotAsked | Loading | Failure | Success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Referential Stability (Preventing Re-renders)
|
||||
|
||||
fp-ts values like `O.some(1)` create new objects each render. React sees them as "changed".
|
||||
|
||||
### The Problem
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Creates new Option every render
|
||||
function BadComponent() {
|
||||
const [value, setValue] = useState(O.some(1))
|
||||
|
||||
useEffect(() => {
|
||||
// This runs EVERY render because O.some(1) !== O.some(1)
|
||||
console.log('value changed')
|
||||
}, [value])
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 1: useMemo
|
||||
|
||||
```typescript
|
||||
// ✅ GOOD: Memoize Option creation
|
||||
function GoodComponent() {
|
||||
const [rawValue, setRawValue] = useState<number | null>(1)
|
||||
|
||||
const value = useMemo(
|
||||
() => O.fromNullable(rawValue),
|
||||
[rawValue] // Only recreate when rawValue changes
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Now this only runs when rawValue actually changes
|
||||
console.log('value changed')
|
||||
}, [rawValue]) // Depend on raw value, not Option
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: fp-ts-react-stable-hooks
|
||||
|
||||
```bash
|
||||
npm install fp-ts-react-stable-hooks
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { useStableO, useStableEffect } from 'fp-ts-react-stable-hooks'
|
||||
import * as O from 'fp-ts/Option'
|
||||
import * as Eq from 'fp-ts/Eq'
|
||||
|
||||
function StableComponent() {
|
||||
// Uses fp-ts equality instead of reference equality
|
||||
const [value, setValue] = useStableO(O.some(1))
|
||||
|
||||
// Effect that understands Option equality
|
||||
useStableEffect(
|
||||
() => { console.log('value changed') },
|
||||
[value],
|
||||
Eq.tuple(O.getEq(Eq.eqNumber)) // Custom equality
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Dependency Injection with Context
|
||||
|
||||
Use ReaderTaskEither for testable components with injected dependencies.
|
||||
|
||||
### Setup Dependencies
|
||||
|
||||
```typescript
|
||||
import * as RTE from 'fp-ts/ReaderTaskEither'
|
||||
import { pipe } from 'fp-ts/function'
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
|
||||
// Define what services your app needs
|
||||
interface AppDependencies {
|
||||
api: {
|
||||
getUser: (id: string) => Promise<User>
|
||||
updateUser: (id: string, data: Partial<User>) => Promise<User>
|
||||
}
|
||||
analytics: {
|
||||
track: (event: string, data?: object) => void
|
||||
}
|
||||
}
|
||||
|
||||
// Create context
|
||||
const DepsContext = createContext<AppDependencies | null>(null)
|
||||
|
||||
// Provider
|
||||
function AppProvider({ deps, children }: { deps: AppDependencies; children: ReactNode }) {
|
||||
return <DepsContext.Provider value={deps}>{children}</DepsContext.Provider>
|
||||
}
|
||||
|
||||
// Hook to use dependencies
|
||||
function useDeps(): AppDependencies {
|
||||
const deps = useContext(DepsContext)
|
||||
if (!deps) throw new Error('Missing AppProvider')
|
||||
return deps
|
||||
}
|
||||
```
|
||||
|
||||
### Use in Components
|
||||
|
||||
```typescript
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const { api, analytics } = useDeps()
|
||||
const [user, setUser] = useState<RemoteData<Error, User>>(notAsked())
|
||||
|
||||
useEffect(() => {
|
||||
setUser(loading())
|
||||
api.getUser(userId)
|
||||
.then(u => {
|
||||
setUser(success(u))
|
||||
analytics.track('user_viewed', { userId })
|
||||
})
|
||||
.catch(e => setUser(failure(e)))
|
||||
}, [userId, api, analytics])
|
||||
|
||||
// render...
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with Mock Dependencies
|
||||
|
||||
```typescript
|
||||
const mockDeps: AppDependencies = {
|
||||
api: {
|
||||
getUser: jest.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
|
||||
updateUser: jest.fn().mockResolvedValue({ id: '1', name: 'Updated' }),
|
||||
},
|
||||
analytics: {
|
||||
track: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
test('loads user on mount', async () => {
|
||||
render(
|
||||
<AppProvider deps={mockDeps}>
|
||||
<UserProfile userId="1" />
|
||||
</AppProvider>
|
||||
)
|
||||
|
||||
await screen.findByText('Test User')
|
||||
expect(mockDeps.api.getUser).toHaveBeenCalledWith('1')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. React 19 Patterns
|
||||
|
||||
### use() for Promises (React 19+)
|
||||
|
||||
```typescript
|
||||
import { use, Suspense } from 'react'
|
||||
|
||||
// Instead of useEffect + useState for data fetching
|
||||
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
|
||||
const user = use(userPromise) // Suspends until resolved
|
||||
return <div>{user.name}</div>
|
||||
}
|
||||
|
||||
// Parent provides the promise
|
||||
function App() {
|
||||
const userPromise = fetchUser('1') // Start fetching immediately
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<UserProfile userPromise={userPromise} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useActionState for Forms (React 19+)
|
||||
|
||||
```typescript
|
||||
import { useActionState } from 'react'
|
||||
import * as E from 'fp-ts/Either'
|
||||
|
||||
interface FormState {
|
||||
errors: string[]
|
||||
success: boolean
|
||||
}
|
||||
|
||||
async function submitForm(
|
||||
prevState: FormState,
|
||||
formData: FormData
|
||||
): Promise<FormState> {
|
||||
const data = {
|
||||
email: formData.get('email') as string,
|
||||
password: formData.get('password') as string,
|
||||
}
|
||||
|
||||
// Use Either for validation
|
||||
const result = pipe(
|
||||
validateForm(data),
|
||||
E.match(
|
||||
(errors) => ({ errors, success: false }),
|
||||
async (valid) => {
|
||||
await saveToServer(valid)
|
||||
return { errors: [], success: true }
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function SignupForm() {
|
||||
const [state, formAction, isPending] = useActionState(submitForm, {
|
||||
errors: [],
|
||||
success: false
|
||||
})
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<input name="email" type="email" />
|
||||
<input name="password" type="password" />
|
||||
|
||||
{state.errors.map(e => <p key={e} className="error">{e}</p>)}
|
||||
|
||||
<button disabled={isPending}>
|
||||
{isPending ? 'Submitting...' : 'Sign Up'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### useOptimistic for Instant Feedback (React 19+)
|
||||
|
||||
```typescript
|
||||
import { useOptimistic } from 'react'
|
||||
|
||||
function TodoList({ todos }: { todos: Todo[] }) {
|
||||
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
|
||||
todos,
|
||||
(state, newTodo: Todo) => [...state, { ...newTodo, pending: true }]
|
||||
)
|
||||
|
||||
const addTodo = async (text: string) => {
|
||||
const newTodo = { id: crypto.randomUUID(), text, done: false }
|
||||
|
||||
// Immediately show in UI
|
||||
addOptimisticTodo(newTodo)
|
||||
|
||||
// Actually save (will reconcile when done)
|
||||
await saveTodo(newTodo)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{optimisticTodos.map(todo => (
|
||||
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
|
||||
{todo.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Common Patterns Cheat Sheet
|
||||
|
||||
### Render Based on Option
|
||||
|
||||
```typescript
|
||||
// Pattern 1: match
|
||||
pipe(
|
||||
maybeUser,
|
||||
O.match(
|
||||
() => <LoginButton />,
|
||||
(user) => <UserMenu user={user} />
|
||||
)
|
||||
)
|
||||
|
||||
// Pattern 2: fold (same as match)
|
||||
O.fold(
|
||||
() => <LoginButton />,
|
||||
(user) => <UserMenu user={user} />
|
||||
)(maybeUser)
|
||||
|
||||
// Pattern 3: getOrElse for simple defaults
|
||||
const name = pipe(
|
||||
maybeUser,
|
||||
O.map(u => u.name),
|
||||
O.getOrElse(() => 'Guest')
|
||||
)
|
||||
```
|
||||
|
||||
### Render Based on Either
|
||||
|
||||
```typescript
|
||||
pipe(
|
||||
validationResult,
|
||||
E.match(
|
||||
(errors) => <ErrorList errors={errors} />,
|
||||
(data) => <SuccessMessage data={data} />
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Safe Array Rendering
|
||||
|
||||
```typescript
|
||||
import * as A from 'fp-ts/Array'
|
||||
|
||||
// Get first item safely
|
||||
const firstUser = pipe(
|
||||
users,
|
||||
A.head,
|
||||
O.map(user => <Featured user={user} />),
|
||||
O.getOrElse(() => <NoFeaturedUser />)
|
||||
)
|
||||
|
||||
// Find specific item
|
||||
const adminUser = pipe(
|
||||
users,
|
||||
A.findFirst(u => u.role === 'admin'),
|
||||
O.map(admin => <AdminBadge user={admin} />),
|
||||
O.toNullable // or O.getOrElse(() => null)
|
||||
)
|
||||
```
|
||||
|
||||
### Conditional Props
|
||||
|
||||
```typescript
|
||||
// Add props only if value exists
|
||||
const modalProps = {
|
||||
isOpen: true,
|
||||
...pipe(
|
||||
maybeTitle,
|
||||
O.map(title => ({ title })),
|
||||
O.getOrElse(() => ({}))
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Situation | Use |
|
||||
|-----------|-----|
|
||||
| Value might not exist | `Option<T>` |
|
||||
| Operation might fail (sync) | `Either<E, A>` |
|
||||
| Async operation might fail | `TaskEither<E, A>` |
|
||||
| Need loading/error/success UI | `RemoteData<E, A>` |
|
||||
| Form with multiple validations | `Either` with validation applicative |
|
||||
| Dependency injection | Context + `ReaderTaskEither` |
|
||||
| Prevent re-renders with fp-ts | `useMemo` or `fp-ts-react-stable-hooks` |
|
||||
|
||||
---
|
||||
|
||||
## Libraries
|
||||
|
||||
- **[fp-ts](https://github.com/gcanti/fp-ts)** - Core library
|
||||
- **[fp-ts-react-stable-hooks](https://github.com/mblink/fp-ts-react-stable-hooks)** - Stable hooks
|
||||
- **[@devexperts/remote-data-ts](https://github.com/devexperts/remote-data-ts)** - RemoteData
|
||||
- **[io-ts](https://github.com/gcanti/io-ts)** - Runtime type validation
|
||||
- **[zod](https://github.com/colinhacks/zod)** - Schema validation (works great with fp-ts)
|
||||
Reference in New Issue
Block a user