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.
Add this skill
npx mdskills install sickn33/fp-ts-errorsComprehensive guide with clear patterns, examples, and comparisons for functional error handling
1---2name: fp-ts-errors3description: 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.4risk: safe5source: https://github.com/whatiskadudoing/fp-ts-skills6---78# Practical Error Handling with fp-ts910This skill teaches you how to handle errors without try/catch spaghetti. No academic jargon - just practical patterns for real problems.1112## When to Use This Skill1314- When you want type-safe error handling in TypeScript15- When replacing try/catch with Either and TaskEither patterns16- When building APIs or services that need explicit error types17- When accumulating multiple validation errors1819The 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.2021---2223## 1. Stop Throwing Everywhere2425### The Problem with Exceptions2627Exceptions are invisible in your types. They break the contract between functions.2829```typescript30// What this function signature promises:31function getUser(id: string): User3233// What it actually does:34function getUser(id: string): User {35 if (!id) throw new Error('ID required')36 const user = db.find(id)37 if (!user) throw new Error('User not found')38 return user39}4041// The caller has no idea this can fail42const user = getUser(id) // Might explode!43```4445You end up with code like this:4647```typescript48// MESSY: try/catch everywhere49function processOrder(orderId: string) {50 let order51 try {52 order = getOrder(orderId)53 } catch (e) {54 console.error('Failed to get order')55 return null56 }5758 let user59 try {60 user = getUser(order.userId)61 } catch (e) {62 console.error('Failed to get user')63 return null64 }6566 let payment67 try {68 payment = chargeCard(user.cardId, order.total)69 } catch (e) {70 console.error('Payment failed')71 return null72 }7374 return { order, user, payment }75}76```7778### The Solution: Return Errors as Values7980```typescript81import * as E from 'fp-ts/Either'82import { pipe } from 'fp-ts/function'8384// Now TypeScript KNOWS this can fail85function getUser(id: string): E.Either<string, User> {86 if (!id) return E.left('ID required')87 const user = db.find(id)88 if (!user) return E.left('User not found')89 return E.right(user)90}9192// The caller is forced to handle both cases93const result = getUser(id)94// result is Either<string, User> - error OR success, never both95```9697---9899## 2. The Result Pattern (Either)100101`Either<E, A>` is simple: it holds either an error (`E`) or a value (`A`).102103- `Left` = error case104- `Right` = success case (think "right" as in "correct")105106```typescript107import * as E from 'fp-ts/Either'108109// Creating values110const success = E.right(42) // Right(42)111const failure = E.left('Oops') // Left('Oops')112113// Checking what you have114if (E.isRight(result)) {115 console.log(result.right) // The success value116} else {117 console.log(result.left) // The error118}119120// Better: pattern match with fold121const message = pipe(122 result,123 E.fold(124 (error) => `Failed: ${error}`,125 (value) => `Got: ${value}`126 )127)128```129130### Converting Throwing Code to Either131132```typescript133// Wrap any throwing function with tryCatch134const parseJSON = (json: string): E.Either<Error, unknown> =>135 E.tryCatch(136 () => JSON.parse(json),137 (e) => (e instanceof Error ? e : new Error(String(e)))138 )139140parseJSON('{"valid": true}') // Right({ valid: true })141parseJSON('not json') // Left(SyntaxError: ...)142143// For functions you'll reuse, use tryCatchK144const safeParseJSON = E.tryCatchK(145 JSON.parse,146 (e) => (e instanceof Error ? e : new Error(String(e)))147)148```149150### Common Either Operations151152```typescript153import * as E from 'fp-ts/Either'154import { pipe } from 'fp-ts/function'155156// Transform the success value157const doubled = pipe(158 E.right(21),159 E.map(n => n * 2)160) // Right(42)161162// Transform the error163const betterError = pipe(164 E.left('bad'),165 E.mapLeft(e => `Error: ${e}`)166) // Left('Error: bad')167168// Provide a default for errors169const value = pipe(170 E.left('failed'),171 E.getOrElse(() => 0)172) // 0173174// Convert nullable to Either175const fromNullable = E.fromNullable('not found')176fromNullable(user) // Right(user) if exists, Left('not found') if null/undefined177```178179---180181## 3. Chaining Operations That Might Fail182183The real power comes from chaining. Each step can fail, but you write it as a clean pipeline.184185### Before: Nested Try/Catch Hell186187```typescript188// MESSY: Each step can fail, nested try/catch everywhere189function processUserOrder(userId: string, productId: string): Result | null {190 let user191 try {192 user = getUser(userId)193 } catch (e) {194 logError('User fetch failed', e)195 return null196 }197198 if (!user.isActive) {199 logError('User not active')200 return null201 }202203 let product204 try {205 product = getProduct(productId)206 } catch (e) {207 logError('Product fetch failed', e)208 return null209 }210211 if (product.stock < 1) {212 logError('Out of stock')213 return null214 }215216 let order217 try {218 order = createOrder(user, product)219 } catch (e) {220 logError('Order creation failed', e)221 return null222 }223224 return order225}226```227228### After: Clean Chain with Either229230```typescript231import * as E from 'fp-ts/Either'232import { pipe } from 'fp-ts/function'233234// Each function returns Either<Error, T>235const getUser = (id: string): E.Either<string, User> => { ... }236const getProduct = (id: string): E.Either<string, Product> => { ... }237const createOrder = (user: User, product: Product): E.Either<string, Order> => { ... }238239// Chain them together - first error stops the chain240const processUserOrder = (userId: string, productId: string): E.Either<string, Order> =>241 pipe(242 getUser(userId),243 E.filterOrElse(244 user => user.isActive,245 () => 'User not active'246 ),247 E.chain(user =>248 pipe(249 getProduct(productId),250 E.filterOrElse(251 product => product.stock >= 1,252 () => 'Out of stock'253 ),254 E.chain(product => createOrder(user, product))255 )256 )257 )258259// Or use Do notation for cleaner access to intermediate values260const processUserOrder = (userId: string, productId: string): E.Either<string, Order> =>261 pipe(262 E.Do,263 E.bind('user', () => getUser(userId)),264 E.filterOrElse(265 ({ user }) => user.isActive,266 () => 'User not active'267 ),268 E.bind('product', () => getProduct(productId)),269 E.filterOrElse(270 ({ product }) => product.stock >= 1,271 () => 'Out of stock'272 ),273 E.chain(({ user, product }) => createOrder(user, product))274 )275```276277### Different Error Types? Use chainW278279```typescript280type ValidationError = { type: 'validation'; message: string }281type DbError = { type: 'db'; message: string }282283const validateInput = (id: string): E.Either<ValidationError, string> => { ... }284const fetchFromDb = (id: string): E.Either<DbError, User> => { ... }285286// chainW (W = "wider") automatically unions the error types287const process = (id: string): E.Either<ValidationError | DbError, User> =>288 pipe(289 validateInput(id),290 E.chainW(validId => fetchFromDb(validId))291 )292```293294---295296## 4. Collecting Multiple Errors297298Sometimes you want ALL errors, not just the first one. Form validation is the classic example.299300### Before: Collecting Errors Manually301302```typescript303// MESSY: Manual error accumulation304function validateForm(form: FormData): { valid: boolean; errors: string[] } {305 const errors: string[] = []306307 if (!form.email) {308 errors.push('Email required')309 } else if (!form.email.includes('@')) {310 errors.push('Invalid email')311 }312313 if (!form.password) {314 errors.push('Password required')315 } else if (form.password.length < 8) {316 errors.push('Password too short')317 }318319 if (!form.age) {320 errors.push('Age required')321 } else if (form.age < 18) {322 errors.push('Must be 18+')323 }324325 return { valid: errors.length === 0, errors }326}327```328329### After: Validation with Error Accumulation330331```typescript332import * as E from 'fp-ts/Either'333import * as NEA from 'fp-ts/NonEmptyArray'334import { sequenceS } from 'fp-ts/Apply'335import { pipe } from 'fp-ts/function'336337// Errors as a NonEmptyArray (always at least one)338type Errors = NEA.NonEmptyArray<string>339340// Create the applicative that accumulates errors341const validation = E.getApplicativeValidation(NEA.getSemigroup<string>())342343// Validators that return Either<Errors, T>344const validateEmail = (email: string): E.Either<Errors, string> =>345 !email ? E.left(NEA.of('Email required'))346 : !email.includes('@') ? E.left(NEA.of('Invalid email'))347 : E.right(email)348349const validatePassword = (password: string): E.Either<Errors, string> =>350 !password ? E.left(NEA.of('Password required'))351 : password.length < 8 ? E.left(NEA.of('Password too short'))352 : E.right(password)353354const validateAge = (age: number | undefined): E.Either<Errors, number> =>355 age === undefined ? E.left(NEA.of('Age required'))356 : age < 18 ? E.left(NEA.of('Must be 18+'))357 : E.right(age)358359// Combine all validations - collects ALL errors360const validateForm = (form: FormData) =>361 sequenceS(validation)({362 email: validateEmail(form.email),363 password: validatePassword(form.password),364 age: validateAge(form.age)365 })366367// Usage368validateForm({ email: '', password: '123', age: 15 })369// Left(['Email required', 'Password too short', 'Must be 18+'])370371validateForm({ email: 'a@b.com', password: 'longpassword', age: 25 })372// Right({ email: 'a@b.com', password: 'longpassword', age: 25 })373```374375### Field-Level Errors for Forms376377```typescript378interface FieldError {379 field: string380 message: string381}382383type FormErrors = NEA.NonEmptyArray<FieldError>384385const fieldError = (field: string, message: string): FormErrors =>386 NEA.of({ field, message })387388const formValidation = E.getApplicativeValidation(NEA.getSemigroup<FieldError>())389390// Now errors know which field they belong to391const validateEmail = (email: string): E.Either<FormErrors, string> =>392 !email ? E.left(fieldError('email', 'Required'))393 : !email.includes('@') ? E.left(fieldError('email', 'Invalid format'))394 : E.right(email)395396// Easy to display in UI397const getFieldError = (errors: FormErrors, field: string): string | undefined =>398 errors.find(e => e.field === field)?.message399```400401---402403## 5. Async Operations (TaskEither)404405For async operations that can fail, use `TaskEither`. It's like `Either` but for promises.406407- `TaskEither<E, A>` = a function that returns `Promise<Either<E, A>>`408- Lazy: nothing runs until you execute it409410```typescript411import * as TE from 'fp-ts/TaskEither'412import { pipe } from 'fp-ts/function'413414// Wrap any async operation415const fetchUser = (id: string): TE.TaskEither<Error, User> =>416 TE.tryCatch(417 () => fetch(`/api/users/${id}`).then(r => r.json()),418 (e) => (e instanceof Error ? e : new Error(String(e)))419 )420421// Chain async operations - just like Either422const getUserPosts = (userId: string): TE.TaskEither<Error, Post[]> =>423 pipe(424 fetchUser(userId),425 TE.chain(user => fetchPosts(user.id))426 )427428// Execute when ready429const result = await getUserPosts('123')() // Returns Either<Error, Post[]>430```431432### Before: Promise Chain with Error Handling433434```typescript435// MESSY: try/catch mixed with promise chains436async function loadDashboard(userId: string) {437 try {438 const user = await fetchUser(userId)439 if (!user) throw new Error('User not found')440441 let posts, notifications, settings442 try {443 [posts, notifications, settings] = await Promise.all([444 fetchPosts(user.id),445 fetchNotifications(user.id),446 fetchSettings(user.id)447 ])448 } catch (e) {449 // Which one failed? Who knows!450 console.error('Failed to load data', e)451 return null452 }453454 return { user, posts, notifications, settings }455 } catch (e) {456 console.error('Failed to load user', e)457 return null458 }459}460```461462### After: Clean TaskEither Pipeline463464```typescript465import * as TE from 'fp-ts/TaskEither'466import { sequenceS } from 'fp-ts/Apply'467import { pipe } from 'fp-ts/function'468469const loadDashboard = (userId: string) =>470 pipe(471 fetchUser(userId),472 TE.chain(user =>473 pipe(474 // Parallel fetch with sequenceS475 sequenceS(TE.ApplyPar)({476 posts: fetchPosts(user.id),477 notifications: fetchNotifications(user.id),478 settings: fetchSettings(user.id)479 }),480 TE.map(data => ({ user, ...data }))481 )482 )483 )484485// Execute and handle both cases486pipe(487 loadDashboard('123'),488 TE.fold(489 (error) => T.of(renderError(error)),490 (data) => T.of(renderDashboard(data))491 )492)()493```494495### Retry Failed Operations496497```typescript498import * as T from 'fp-ts/Task'499import * as TE from 'fp-ts/TaskEither'500import { pipe } from 'fp-ts/function'501502const retry = <E, A>(503 task: TE.TaskEither<E, A>,504 attempts: number,505 delayMs: number506): TE.TaskEither<E, A> =>507 pipe(508 task,509 TE.orElse((error) =>510 attempts > 1511 ? pipe(512 T.delay(delayMs)(T.of(undefined)),513 T.chain(() => retry(task, attempts - 1, delayMs * 2))514 )515 : TE.left(error)516 )517 )518519// Retry up to 3 times with exponential backoff520const fetchWithRetry = retry(fetchUser('123'), 3, 1000)521```522523### Fallback to Alternative524525```typescript526// Try cache first, fall back to API527const getUserData = (id: string) =>528 pipe(529 fetchFromCache(id),530 TE.orElse(() => fetchFromApi(id)),531 TE.orElse(() => TE.right(defaultUser)) // Last resort default532 )533```534535---536537## 6. Converting Between Patterns538539Real codebases have throwing functions, nullable values, and promises. Here's how to work with them.540541### From Nullable to Either542543```typescript544import * as E from 'fp-ts/Either'545import * as O from 'fp-ts/Option'546547// Direct conversion548const user = users.find(u => u.id === id) // User | undefined549const result = E.fromNullable('User not found')(user)550551// From Option552const maybeUser: O.Option<User> = O.fromNullable(user)553const eitherUser = pipe(554 maybeUser,555 E.fromOption(() => 'User not found')556)557```558559### From Throwing Function to Either560561```typescript562// Wrap at the boundary563const safeParse = <T>(schema: ZodSchema<T>) => (data: unknown): E.Either<ZodError, T> =>564 E.tryCatch(565 () => schema.parse(data),566 (e) => e as ZodError567 )568569// Use throughout your code570const parseUser = safeParse(UserSchema)571const result = parseUser(rawData) // Either<ZodError, User>572```573574### From Promise to TaskEither575576```typescript577import * as TE from 'fp-ts/TaskEither'578579// Wrap external async functions580const fetchJson = <T>(url: string): TE.TaskEither<Error, T> =>581 TE.tryCatch(582 () => fetch(url).then(r => r.json()),583 (e) => new Error(`Fetch failed: ${e}`)584 )585586// Wrap axios, prisma, any async library587const getUserFromDb = (id: string): TE.TaskEither<DbError, User> =>588 TE.tryCatch(589 () => prisma.user.findUniqueOrThrow({ where: { id } }),590 (e) => ({ code: 'DB_ERROR', cause: e })591 )592```593594### Back to Promise (Escape Hatch)595596Sometimes you need a plain Promise for external APIs.597598```typescript599import * as TE from 'fp-ts/TaskEither'600import * as E from 'fp-ts/Either'601602const myTaskEither: TE.TaskEither<Error, User> = fetchUser('123')603604// Option 1: Get the Either (preserves both cases)605const either: E.Either<Error, User> = await myTaskEither()606607// Option 2: Throw on error (for legacy code)608const toThrowingPromise = <E, A>(te: TE.TaskEither<E, A>): Promise<A> =>609 te().then(E.fold(610 (error) => Promise.reject(error),611 (value) => Promise.resolve(value)612 ))613614const user = await toThrowingPromise(fetchUser('123')) // Throws if Left615616// Option 3: Default on error617const user = await pipe(618 fetchUser('123'),619 TE.getOrElse(() => T.of(defaultUser))620)()621```622623---624625## Real Scenarios626627### Parse User Input Safely628629```typescript630interface ParsedInput {631 id: number632 name: string633 tags: string[]634}635636const parseInput = (raw: unknown): E.Either<string, ParsedInput> =>637 pipe(638 E.Do,639 E.bind('obj', () =>640 typeof raw === 'object' && raw !== null641 ? E.right(raw as Record<string, unknown>)642 : E.left('Input must be an object')643 ),644 E.bind('id', ({ obj }) =>645 typeof obj.id === 'number'646 ? E.right(obj.id)647 : E.left('id must be a number')648 ),649 E.bind('name', ({ obj }) =>650 typeof obj.name === 'string' && obj.name.length > 0651 ? E.right(obj.name)652 : E.left('name must be a non-empty string')653 ),654 E.bind('tags', ({ obj }) =>655 Array.isArray(obj.tags) && obj.tags.every(t => typeof t === 'string')656 ? E.right(obj.tags as string[])657 : E.left('tags must be an array of strings')658 ),659 E.map(({ id, name, tags }) => ({ id, name, tags }))660 )661662// Usage663parseInput({ id: 1, name: 'test', tags: ['a', 'b'] })664// Right({ id: 1, name: 'test', tags: ['a', 'b'] })665666parseInput({ id: 'wrong', name: '', tags: null })667// Left('id must be a number')668```669670### API Call with Full Error Handling671672```typescript673interface ApiError {674 code: string675 message: string676 status?: number677}678679const createApiError = (message: string, code = 'UNKNOWN', status?: number): ApiError =>680 ({ code, message, status })681682const fetchWithErrorHandling = <T>(url: string): TE.TaskEither<ApiError, T> =>683 pipe(684 TE.tryCatch(685 () => fetch(url),686 () => createApiError('Network error', 'NETWORK')687 ),688 TE.chain(response =>689 response.ok690 ? TE.tryCatch(691 () => response.json() as Promise<T>,692 () => createApiError('Invalid JSON', 'PARSE')693 )694 : TE.left(createApiError(695 `HTTP ${response.status}`,696 response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',697 response.status698 ))699 )700 )701702// Usage with pattern matching on error codes703const handleUserFetch = (userId: string) =>704 pipe(705 fetchWithErrorHandling<User>(`/api/users/${userId}`),706 TE.fold(707 (error) => {708 switch (error.code) {709 case 'NOT_FOUND': return T.of(showNotFoundPage())710 case 'NETWORK': return T.of(showOfflineMessage())711 default: return T.of(showGenericError(error.message))712 }713 },714 (user) => T.of(showUserProfile(user))715 )716 )717```718719### Process List Where Some Items Might Fail720721```typescript722import * as A from 'fp-ts/Array'723import * as E from 'fp-ts/Either'724import { pipe } from 'fp-ts/function'725726interface ProcessResult<T> {727 successes: T[]728 failures: Array<{ item: unknown; error: string }>729}730731// Process all, collect successes and failures separately732const processAllCollectErrors = <T, R>(733 items: T[],734 process: (item: T) => E.Either<string, R>735): ProcessResult<R> => {736 const results = items.map((item, index) =>737 pipe(738 process(item),739 E.mapLeft(error => ({ item, error, index }))740 )741 )742743 return {744 successes: pipe(results, A.filterMap(E.toOption)),745 failures: pipe(746 results,747 A.filterMap(r => E.isLeft(r) ? O.some(r.left) : O.none)748 )749 }750}751752// Usage753const parseNumbers = (inputs: string[]) =>754 processAllCollectErrors(inputs, input => {755 const n = parseInt(input, 10)756 return isNaN(n) ? E.left(`Invalid number: ${input}`) : E.right(n)757 })758759parseNumbers(['1', 'abc', '3', 'def'])760// {761// successes: [1, 3],762// failures: [763// { item: 'abc', error: 'Invalid number: abc', index: 1 },764// { item: 'def', error: 'Invalid number: def', index: 3 }765// ]766// }767```768769### Bulk Operations with Partial Success770771```typescript772import * as TE from 'fp-ts/TaskEither'773import * as T from 'fp-ts/Task'774import { pipe } from 'fp-ts/function'775776interface BulkResult<T> {777 succeeded: T[]778 failed: Array<{ id: string; error: string }>779}780781const bulkProcess = <T>(782 ids: string[],783 process: (id: string) => TE.TaskEither<string, T>784): T.Task<BulkResult<T>> =>785 pipe(786 ids,787 A.map(id =>788 pipe(789 process(id),790 TE.fold(791 (error) => T.of({ type: 'failed' as const, id, error }),792 (result) => T.of({ type: 'succeeded' as const, result })793 )794 )795 ),796 T.sequenceArray,797 T.map(results => ({798 succeeded: results799 .filter((r): r is { type: 'succeeded'; result: T } => r.type === 'succeeded')800 .map(r => r.result),801 failed: results802 .filter((r): r is { type: 'failed'; id: string; error: string } => r.type === 'failed')803 .map(({ id, error }) => ({ id, error }))804 }))805 )806807// Usage808const deleteUsers = (userIds: string[]) =>809 bulkProcess(userIds, id =>810 pipe(811 deleteUser(id),812 TE.mapLeft(e => e.message)813 )814 )815816// All operations run, you get a report of what worked and what didn't817```818819---820821## Quick Reference822823| Pattern | Use When | Example |824|---------|----------|---------|825| `E.right(value)` | Creating a success | `E.right(42)` |826| `E.left(error)` | Creating a failure | `E.left('not found')` |827| `E.tryCatch(fn, onError)` | Wrapping throwing code | `E.tryCatch(() => JSON.parse(s), toError)` |828| `E.fromNullable(error)` | Converting nullable | `E.fromNullable('missing')(maybeValue)` |829| `E.map(fn)` | Transform success | `pipe(result, E.map(x => x * 2))` |830| `E.mapLeft(fn)` | Transform error | `pipe(result, E.mapLeft(addContext))` |831| `E.chain(fn)` | Chain operations | `pipe(getA(), E.chain(a => getB(a.id)))` |832| `E.chainW(fn)` | Chain with different error type | `pipe(validate(), E.chainW(save))` |833| `E.fold(onError, onSuccess)` | Handle both cases | `E.fold(showError, showData)` |834| `E.getOrElse(onError)` | Extract with default | `E.getOrElse(() => 0)` |835| `E.filterOrElse(pred, onFalse)` | Validate with error | `E.filterOrElse(x => x > 0, () => 'must be positive')` |836| `sequenceS(validation)({...})` | Collect all errors | Form validation |837838### TaskEither Equivalents839840All Either operations have TaskEither equivalents:841- `TE.right`, `TE.left`, `TE.tryCatch`842- `TE.map`, `TE.mapLeft`, `TE.chain`, `TE.chainW`843- `TE.fold`, `TE.getOrElse`, `TE.filterOrElse`844- `TE.orElse` for fallbacks845846---847848## Summary8498501. **Return errors as values** - Use Either/TaskEither instead of throwing8512. **Chain with confidence** - `chain` stops at first error automatically8523. **Collect all errors when needed** - Use validation applicative for forms8534. **Wrap at boundaries** - Convert throwing/Promise code at the edges8545. **Match at the end** - Use `fold` to handle both cases when you're ready to act855856The payoff: TypeScript tracks your errors, no more forgotten try/catch, clear control flow, and composable error handling.857
Full transparency — inspect the skill content before installing.