# Pragmatic Functional Programming - Detailed Guide > This file contains the detailed procedure and reference material extracted from `SKILL.md` for focused loading. The root skill defines activation, examples, safety constraints, and limitations. Examples target fp-ts 2.x with flatMap available; check the installed version. Before/after blocks are alternative snippets, not one file with duplicate declarations. Import the project’s User/Item types and validate external JSON at runtime; a type annotation does not make untrusted data conform. TaskEither is lazy: calling the returned thunk executes the effect each time, so do not repeat payment/network thunks as retries without idempotency. ## 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 { if (!/^[0-9]+$/.test(input.trim())) throw new Error('Invalid age') const age = Number(input) if (!Number.isSafeInteger(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 { if (!/^[0-9]+$/.test(input.trim())) return E.left('Invalid age') const age = Number(input) if (!Number.isSafeInteger(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 = O.some({ name: 'Alice', age: 30 }) const maybeName: O.Option = pipe( maybeUser, O.map(user => user.name) ) // Transform inside Either const result: E.Either = E.right(5) const doubled: E.Either = pipe( result, E.map(n => n * 2) ) // Transform arrays (same concept!) const numbers = [1, 2, 3] const doubledNumbers = 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 => E.tryCatch(() => JSON.parse(s), () => 'Invalid JSON') const extractEmail = (data: unknown): E.Either => { if (typeof data === 'object' && data !== null && 'email' in data && typeof data.email === 'string') { return E.right(data.email) } return E.left('No email field') } const validateEmail = (email: string): E.Either => 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 => 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" --- ## 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 { 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 { // ... return E.left('validation failed') } // After: Structured errors type ValidationError = { field: string message: string } function validate(data: unknown): E.Either { // ... 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 Unauthorized | ReturnType // 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 The before fetch functions use callbacks; the after functions must be explicit TaskEither adapters, not the same callback APIs magically changing type. Define tryCatch adapters with validated response contracts before using this pattern. ```typescript // Before fetchUser(id, (user) => { if (!user) return handleNoUser() fetchPosts(user.id, (posts) => { if (!posts) return handleNoPosts() if (posts.length === 0) return render(user, posts, []) 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( posts.length === 0 ? TE.right([]) : 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 => 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 User in this fragment means the locally defined { email: string; age: number } shape; richer project User types need all required fields validated. The includes(@) check is only a teaching simplification, not a production address validator. ```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 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 => pipe( E.Do, E.bind('obj', () => typeof data === 'object' && data !== null ? E.right(data as Record) : 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' && Number.isSafeInteger(obj.age) && 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 { 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 => 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