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.
Add this skill
npx mdskills install sickn33/fp-ts-pragmaticExcellent pragmatic guide to fp-ts with clear examples, anti-patterns, and refactoring patterns
1---2name: fp-ts-pragmatic3description: 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.4risk: safe5source: https://github.com/whatiskadudoing/fp-ts-skills6---78# Pragmatic Functional Programming910**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.1112## When to Use This Skill1314- When starting with fp-ts and need practical guidance15- When writing TypeScript code that handles nullable values, errors, or async operations16- When you want cleaner, more maintainable functional code without the academic overhead17- When refactoring imperative code to functional style1819## The Golden Rule2021> **If functional programming makes your code harder to read, don't use it.**2223FP is a tool, not a religion. Use it when it helps. Skip it when it doesn't.2425---2627## The 80/20 of FP2829These five patterns give you most of the benefits. Master these before exploring anything else.3031### 1. Pipe: Chain Operations Clearly3233Instead of nesting function calls or creating intermediate variables, chain operations in reading order.3435```typescript36import { pipe } from 'fp-ts/function'3738// Before: Hard to read (inside-out)39const result = format(validate(parse(input)))4041// Before: Too many variables42const parsed = parse(input)43const validated = validate(parsed)44const result = format(validated)4546// After: Clear, linear flow47const result = pipe(48 input,49 parse,50 validate,51 format52)53```5455**When to use pipe:**56- 3+ transformations on the same data57- You find yourself naming throwaway variables58- Logic reads better top-to-bottom5960**When to skip pipe:**61- Just 1-2 operations (direct call is fine)62- The operations don't naturally chain6364### 2. Option: Handle Missing Values Without null Checks6566Stop writing `if (x !== null && x !== undefined)` everywhere.6768```typescript69import * as O from 'fp-ts/Option'70import { pipe } from 'fp-ts/function'7172// Before: Defensive null checking73function getUserCity(user: User | null): string {74 if (user === null) return 'Unknown'75 if (user.address === null) return 'Unknown'76 if (user.address.city === null) return 'Unknown'77 return user.address.city78}7980// After: Chain through potential missing values81const getUserCity = (user: User | null): string =>82 pipe(83 O.fromNullable(user),84 O.flatMap(u => O.fromNullable(u.address)),85 O.flatMap(a => O.fromNullable(a.city)),86 O.getOrElse(() => 'Unknown')87 )88```8990**Plain language translation:**91- `O.fromNullable(x)` = "wrap this value, treating null/undefined as 'nothing'"92- `O.flatMap(fn)` = "if we have something, apply this function"93- `O.getOrElse(() => default)` = "unwrap, or use this default if nothing"9495### 3. Either: Make Errors Explicit9697Stop throwing exceptions for expected failures. Return errors as values.9899```typescript100import * as E from 'fp-ts/Either'101import { pipe } from 'fp-ts/function'102103// Before: Hidden failure mode104function parseAge(input: string): number {105 const age = parseInt(input, 10)106 if (isNaN(age)) throw new Error('Invalid age')107 if (age < 0) throw new Error('Age cannot be negative')108 return age109}110111// After: Errors are visible in the type112function parseAge(input: string): E.Either<string, number> {113 const age = parseInt(input, 10)114 if (isNaN(age)) return E.left('Invalid age')115 if (age < 0) return E.left('Age cannot be negative')116 return E.right(age)117}118119// Using it120const result = parseAge(userInput)121if (E.isRight(result)) {122 console.log(`Age is ${result.right}`)123} else {124 console.log(`Error: ${result.left}`)125}126```127128**Plain language translation:**129- `E.right(value)` = "success with this value"130- `E.left(error)` = "failure with this error"131- `E.isRight(x)` = "did it succeed?"132133### 4. Map: Transform Without Unpacking134135Transform values inside containers without extracting them first.136137```typescript138import * as O from 'fp-ts/Option'139import * as E from 'fp-ts/Either'140import * as A from 'fp-ts/Array'141import { pipe } from 'fp-ts/function'142143// Transform inside Option144const maybeUser: O.Option<User> = O.some({ name: 'Alice', age: 30 })145const maybeName: O.Option<string> = pipe(146 maybeUser,147 O.map(user => user.name)148)149150// Transform inside Either151const result: E.Either<Error, number> = E.right(5)152const doubled: E.Either<Error, number> = pipe(153 result,154 E.map(n => n * 2)155)156157// Transform arrays (same concept!)158const numbers = [1, 2, 3]159const doubled = pipe(160 numbers,161 A.map(n => n * 2)162)163```164165### 5. FlatMap: Chain Operations That Might Fail166167When each step might fail, chain them together.168169```typescript170import * as E from 'fp-ts/Either'171import { pipe } from 'fp-ts/function'172173const parseJSON = (s: string): E.Either<string, unknown> =>174 E.tryCatch(() => JSON.parse(s), () => 'Invalid JSON')175176const extractEmail = (data: unknown): E.Either<string, string> => {177 if (typeof data === 'object' && data !== null && 'email' in data) {178 return E.right((data as { email: string }).email)179 }180 return E.left('No email field')181}182183const validateEmail = (email: string): E.Either<string, string> =>184 email.includes('@') ? E.right(email) : E.left('Invalid email format')185186// Chain all steps - if any fails, the whole thing fails187const getValidEmail = (input: string): E.Either<string, string> =>188 pipe(189 parseJSON(input),190 E.flatMap(extractEmail),191 E.flatMap(validateEmail)192 )193194// Success path: Right('user@example.com')195// Any failure: Left('specific error message')196```197198**Plain language:** `flatMap` means "if this succeeded, try the next thing"199200---201202## When NOT to Use FP203204Functional programming is not always the answer. Here's when to keep it simple.205206### Simple Null Checks207208```typescript209// Just use optional chaining - it's built into the language210const city = user?.address?.city ?? 'Unknown'211212// DON'T overcomplicate it213const city = pipe(214 O.fromNullable(user),215 O.flatMap(u => O.fromNullable(u.address)),216 O.flatMap(a => O.fromNullable(a.city)),217 O.getOrElse(() => 'Unknown')218)219```220221### Simple Loops222223```typescript224// A for loop is fine when you need early exit or complex logic225function findFirst(items: Item[], predicate: (i: Item) => boolean): Item | null {226 for (const item of items) {227 if (predicate(item)) return item228 }229 return null230}231232// DON'T force FP when it doesn't help233const result = pipe(234 items,235 A.findFirst(predicate),236 O.toNullable237)238```239240### Performance-Critical Code241242```typescript243// For hot paths, imperative is faster (no intermediate arrays)244function sumLarge(numbers: number[]): number {245 let sum = 0246 for (let i = 0; i < numbers.length; i++) {247 sum += numbers[i]248 }249 return sum250}251252// fp-ts creates intermediate structures253const sum = pipe(numbers, A.reduce(0, (acc, n) => acc + n))254```255256### When Your Team Doesn't Know FP257258If you're the only one who can read the code, it's not good code.259260```typescript261// If your team knows this pattern262async function getUser(id: string): Promise<User | null> {263 try {264 const response = await fetch(`/api/users/${id}`)265 if (!response.ok) return null266 return await response.json()267 } catch {268 return null269 }270}271272// Don't force this on them273const getUser = (id: string): TE.TaskEither<Error, User> =>274 pipe(275 TE.tryCatch(() => fetch(`/api/users/${id}`), E.toError),276 TE.flatMap(r => r.ok ? TE.right(r) : TE.left(new Error('Not found'))),277 TE.flatMap(r => TE.tryCatch(() => r.json(), E.toError))278 )279```280281---282283## Quick Wins: Easy Changes That Improve Code Today284285### 1. Replace Nested Ternaries with pipe + fold286287```typescript288// Before: Nested ternary nightmare289const message = user === null290 ? 'No user'291 : user.isAdmin292 ? `Admin: ${user.name}`293 : `User: ${user.name}`294295// After: Clear case handling296const message = pipe(297 O.fromNullable(user),298 O.fold(299 () => 'No user',300 (u) => u.isAdmin ? `Admin: ${u.name}` : `User: ${u.name}`301 )302)303```304305### 2. Replace try-catch with tryCatch306307```typescript308// Before: try-catch everywhere309let config310try {311 config = JSON.parse(rawConfig)312} catch {313 config = defaultConfig314}315316// After: One-liner317const config = pipe(318 E.tryCatch(() => JSON.parse(rawConfig), () => 'parse error'),319 E.getOrElse(() => defaultConfig)320)321```322323### 3. Replace undefined Returns with Option324325```typescript326// Before: Caller might forget to check327function findUser(id: string): User | undefined {328 return users.find(u => u.id === id)329}330331// After: Type forces caller to handle missing case332function findUser(id: string): O.Option<User> {333 return O.fromNullable(users.find(u => u.id === id))334}335```336337### 4. Replace Error Strings with Typed Errors338339```typescript340// Before: Just strings341function validate(data: unknown): E.Either<string, User> {342 // ...343 return E.left('validation failed')344}345346// After: Structured errors347type ValidationError = {348 field: string349 message: string350}351352function validate(data: unknown): E.Either<ValidationError, User> {353 // ...354 return E.left({ field: 'email', message: 'Invalid format' })355}356```357358### 5. Use const Assertions for Error Types359360```typescript361// Create specific error types without classes362const NotFound = (id: string) => ({ _tag: 'NotFound' as const, id })363const Unauthorized = { _tag: 'Unauthorized' as const }364const ValidationFailed = (errors: string[]) =>365 ({ _tag: 'ValidationFailed' as const, errors })366367type AppError =368 | ReturnType<typeof NotFound>369 | typeof Unauthorized370 | ReturnType<typeof ValidationFailed>371372// Now you can pattern match373const handleError = (error: AppError): string => {374 switch (error._tag) {375 case 'NotFound': return `Item ${error.id} not found`376 case 'Unauthorized': return 'Please log in'377 case 'ValidationFailed': return error.errors.join(', ')378 }379}380```381382---383384## Common Refactors: Before and After385386### Callback Hell to Pipe387388```typescript389// Before390fetchUser(id, (user) => {391 if (!user) return handleNoUser()392 fetchPosts(user.id, (posts) => {393 if (!posts) return handleNoPosts()394 fetchComments(posts[0].id, (comments) => {395 render(user, posts, comments)396 })397 })398})399400// After (with TaskEither for async)401import * as TE from 'fp-ts/TaskEither'402403const loadData = (id: string) =>404 pipe(405 fetchUser(id),406 TE.flatMap(user => pipe(407 fetchPosts(user.id),408 TE.map(posts => ({ user, posts }))409 )),410 TE.flatMap(({ user, posts }) => pipe(411 fetchComments(posts[0].id),412 TE.map(comments => ({ user, posts, comments }))413 ))414 )415416// Execute417const result = await loadData('123')()418pipe(419 result,420 E.fold(handleError, ({ user, posts, comments }) => render(user, posts, comments))421)422```423424### Multiple null Checks to Option Chain425426```typescript427// Before428function getManagerEmail(employee: Employee): string | null {429 if (!employee.department) return null430 if (!employee.department.manager) return null431 if (!employee.department.manager.email) return null432 return employee.department.manager.email433}434435// After436const getManagerEmail = (employee: Employee): O.Option<string> =>437 pipe(438 O.fromNullable(employee.department),439 O.flatMap(d => O.fromNullable(d.manager)),440 O.flatMap(m => O.fromNullable(m.email))441 )442443// Use it444pipe(445 getManagerEmail(employee),446 O.fold(447 () => sendToDefault(),448 (email) => sendTo(email)449 )450)451```452453### Validation with Multiple Checks454455```typescript456// Before: Throws on first error457function validateUser(data: unknown): User {458 if (!data || typeof data !== 'object') throw new Error('Must be object')459 const obj = data as Record<string, unknown>460 if (typeof obj.email !== 'string') throw new Error('Email required')461 if (!obj.email.includes('@')) throw new Error('Invalid email')462 if (typeof obj.age !== 'number') throw new Error('Age required')463 if (obj.age < 0) throw new Error('Age must be positive')464 return obj as User465}466467// After: Returns first error, type-safe468const validateUser = (data: unknown): E.Either<string, User> =>469 pipe(470 E.Do,471 E.bind('obj', () =>472 typeof data === 'object' && data !== null473 ? E.right(data as Record<string, unknown>)474 : E.left('Must be object')475 ),476 E.bind('email', ({ obj }) =>477 typeof obj.email === 'string' && obj.email.includes('@')478 ? E.right(obj.email)479 : E.left('Valid email required')480 ),481 E.bind('age', ({ obj }) =>482 typeof obj.age === 'number' && obj.age >= 0483 ? E.right(obj.age)484 : E.left('Valid age required')485 ),486 E.map(({ email, age }) => ({ email, age }))487 )488```489490### Promise Chain to TaskEither491492```typescript493// Before494async function processOrder(orderId: string): Promise<Receipt> {495 const order = await fetchOrder(orderId)496 if (!order) throw new Error('Order not found')497498 const validated = await validateOrder(order)499 if (!validated.success) throw new Error(validated.error)500501 const payment = await processPayment(validated.order)502 if (!payment.success) throw new Error('Payment failed')503504 return generateReceipt(payment)505}506507// After508const processOrder = (orderId: string): TE.TaskEither<string, Receipt> =>509 pipe(510 fetchOrderTE(orderId),511 TE.flatMap(order =>512 order ? TE.right(order) : TE.left('Order not found')513 ),514 TE.flatMap(validateOrderTE),515 TE.flatMap(processPaymentTE),516 TE.map(generateReceipt)517 )518```519520---521522## The Readability Rule523524Before using any FP pattern, ask: **"Would a junior developer understand this?"**525526### Too Clever (Avoid)527528```typescript529const result = pipe(530 data,531 A.filter(flow(prop('status'), equals('active'))),532 A.map(flow(prop('value'), multiply(2))),533 A.reduce(monoid.concat, monoid.empty),534 O.fromPredicate(gt(threshold))535)536```537538### Just Right (Prefer)539540```typescript541const activeItems = data.filter(item => item.status === 'active')542const doubledValues = activeItems.map(item => item.value * 2)543const total = doubledValues.reduce((sum, val) => sum + val, 0)544const result = total > threshold ? O.some(total) : O.none545```546547### The Middle Ground (Often Best)548549```typescript550const result = pipe(551 data,552 A.filter(item => item.status === 'active'),553 A.map(item => item.value * 2),554 A.reduce(0, (sum, val) => sum + val),555 total => total > threshold ? O.some(total) : O.none556)557```558559---560561## Cheat Sheet562563| What you want | Plain language | fp-ts |564|--------------|----------------|-------|565| Handle null/undefined | "Wrap this nullable" | `O.fromNullable(x)` |566| Default for missing | "Use this if nothing" | `O.getOrElse(() => default)` |567| Transform if present | "If something, change it" | `O.map(fn)` |568| Chain nullable operations | "If something, try this" | `O.flatMap(fn)` |569| Return success | "Worked, here's the value" | `E.right(value)` |570| Return failure | "Failed, here's why" | `E.left(error)` |571| Wrap throwing function | "Try this, catch errors" | `E.tryCatch(fn, onError)` |572| Handle both cases | "Do this for error, that for success" | `E.fold(onLeft, onRight)` |573| Chain operations | "Then do this, then that" | `pipe(x, fn1, fn2, fn3)` |574575---576577## When to Level Up578579Once comfortable with these patterns, explore:5805811. **TaskEither** - Async operations that can fail (replaces Promise + try/catch)5822. **Validation** - Collect ALL errors instead of stopping at first5833. **Reader** - Dependency injection without classes5844. **Do notation** - Cleaner syntax for multiple bindings585586But don't rush. The basics here will handle 80% of real-world scenarios. Get comfortable with these before adding more tools to your belt.587588---589590## Summary5915921. **Use pipe** for 3+ operations5932. **Use Option** for nullable chains5943. **Use Either** for operations that can fail5954. **Use map** to transform wrapped values5965. **Use flatMap** to chain operations that might fail5976. **Skip FP** when it hurts readability5987. **Keep it simple** - if your team can't read it, it's not good code599
Full transparency — inspect the skill content before installing.