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.
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.
- Stop Throwing Everywhere
The Problem with Exceptions
Exceptions are invisible in your types. They break the contract between functions.
// 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:
// 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
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
- 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")
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
// 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
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
- 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
// 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
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
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)) )
- Collecting Multiple Errors
Sometimes you want ALL errors, not just the first one. Form validation is the classic example.
Before: Collecting Errors Manually
// 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
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
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
- 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
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
// 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
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
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
// 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 )
- Converting Between Patterns
Real codebases have throwing functions, nullable values, and promises. Here's how to work with them.
From Nullable to Either
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
// 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
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.
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
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
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
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
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
-
Return errors as values - Use Either/TaskEither instead of throwing
-
Chain with confidence - chain stops at first error automatically
-
Collect all errors when needed - Use validation applicative for forms
-
Wrap at boundaries - Convert throwing/Promise code at the edges
-
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.