Practical Error Handling with fp-ts
Stop throwing everywhere. Treat errors as values that TypeScript can track through Either and TaskEither. This skill gives you pragmatic, copy-pasteable patterns for real application code — no academic jargon.
Core idea: Errors are just data. Return them instead of throwing them into the void.
When to Use
- You need to replace exception-heavy code with
EitherorTaskEither. - The task involves validation, domain errors, or clearer error contracts in TypeScript.
- You want pragmatic fp-ts error-handling guidance for real application code.
- You are chaining multiple fallible operations and want clean pipelines instead of nested try/catch.
- You need to collect multiple validation errors (e.g., form fields) rather than failing on the first.
Prerequisites
- TypeScript project with
fp-tsinstalled. - Install if missing (PowerShell, Windows host):
npm install fp-ts
- Imports used throughout this skill:
import * as E from 'fp-ts/Either'
import * as TE from 'fp-ts/TaskEither'
import * as O from 'fp-ts/Option'
import * as T from 'fp-ts/Task'
import * as A from 'fp-ts/Array'
import * as NEA from 'fp-ts/NonEmptyArray'
import { sequenceS } from 'fp-ts/Apply'
import { pipe } from 'fp-ts/function'
Procedure
1. Return Errors as Values with Either
Either<E, A> holds either an error (Left) or a value (Right). TypeScript now sees failure in the type signature.
// Before: exceptions are invisible in types
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
}
// After: 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)
}
Creating and inspecting values:
const success = E.right(42) // Right(42)
const failure = E.left('Oops') // Left('Oops')
// Pattern match with fold
const message = pipe(
result,
E.fold(
(error) => `Failed: ${error}`,
(value) => `Got: ${value}`
)
)
2. Convert 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 reusable functions, use tryCatchK
const safeParseJSON = E.tryCatchK(
JSON.parse,
(e) => (e instanceof Error ? e : new Error(String(e)))
)
3. Use Common Either Operations
// 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
4. Chain Fallible Operations
Each step can fail; the first error stops the chain. Replace nested try/catch with a pipeline.
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> => { /* ... */ }
// Using chain + filterOrElse
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))
)
)
)
// Using Do notation for cleaner intermediate access
const processUserOrderDo = (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 to widen:
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> => { /* ... */ }
const process = (id: string): E.Either<ValidationError | DbError, User> =>
pipe(
validateInput(id),
E.chainW(validId => fetchFromDb(validId))
)
5. Collect Multiple Errors with Validation Applicative
For forms and batch validation, accumulate all errors instead of stopping at the first.
type Errors = NEA.NonEmptyArray<string>
const validation = E.getApplicativeValidation(NEA.getSemigroup<string>())
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)
const validateForm = (form: FormData) =>
sequenceS(validation)({
email: validateEmail(form.email),
password: validatePassword(form.password),
age: validateAge(form.age)
})
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 UI display:
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>())
const validateEmailField = (email: string): E.Either<FormErrors, string> =>
!email ? E.left(fieldError('email', 'Required'))
: !email.includes('@') ? E.left(fieldError('email', 'Invalid format'))
: E.right(email)
const getFieldError = (errors: FormErrors, field: string): string | undefined =>
errors.find(e => e.field === field)?.message
6. Handle Async with TaskEither
TaskEither<E, A> is a lazy function returning Promise<Either<E, A>>. Nothing runs until you execute it.
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)))
)
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[]>
Parallel fetch with sequenceS:
const loadDashboard = (userId: string) =>
pipe(
fetchUser(userId),
TE.chain(user =>
pipe(
sequenceS(TE.ApplyPar)({
posts: fetchPosts(user.id),
notifications: fetchNotifications(user.id),
settings: fetchSettings(user.id)
}),
TE.map(data => ({ user, ...data }))
)
)
)
Retry with exponential backoff:
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)
)
)
const fetchWithRetry = retry(fetchUser('123'), 3, 1000)
Fallback chain:
const getUserData = (id: string) =>
pipe(
fetchFromCache(id),
TE.orElse(() => fetchFromApi(id)),
TE.orElse(() => TE.right(defaultUser))
)
7. Convert Between Patterns
From nullable to Either:
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 (e.g., Zod) to Either:
const safeParse = <T>(schema: ZodSchema<T>) => (data: unknown): E.Either<ZodError, T> =>
E.tryCatch(
() => schema.parse(data),
(e) => e as ZodError
)
From Promise to TaskEither:
const fetchJson = <T>(url: string): TE.TaskEither<Error, T> =>
TE.tryCatch(
() => fetch(url).then(r => r.json()),
(e) => new Error(`Fetch failed: ${e}`)
)
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 for legacy APIs):
// 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)
))
// Option 3: Default on error
const user = await pipe(
fetchUser('123'),
TE.getOrElse(() => T.of(defaultUser))
)()
8. Real Scenarios
Parse user input safely with Do notation:
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 }))
)
API call with structured error codes:
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
))
)
)
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 a list collecting successes and failures separately:
interface ProcessResult<T> {
successes: T[]
failures: Array<{ item: unknown; error: string }>
}
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)
)
}
}
parseNumbers(['1', 'abc', '3', 'def'])
// { successes: [1, 3], failures: [{ item: 'abc', error: '...', index: 1 }, ...] }
Bulk async with partial success report:
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 }))
}))
)
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: TE.right, TE.left, TE.tryCatch, TE.map, TE.mapLeft, TE.chain, TE.chainW, TE.fold, TE.getOrElse, TE.filterOrElse, TE.orElse.
Pitfalls
- Don't throw inside Either/TaskEither pipelines. Throwing breaks the typed contract you are trying to enforce. Use
E.tryCatch/TE.tryCatchat boundaries to wrap throwing code. chainstops at the first error. If you need all errors (e.g., forms), use the validation applicative withsequenceSandNEA.getSemigroup, notchain.chainrequires identical error types. UsechainWwhen chaining functions with different error types so TypeScript can union them.- TaskEither is lazy. Nothing executes until you call the resulting function with
(). Forgetting to invoke is a common silent bug. E.fromNullableis curried. Call asE.fromNullable('error')(value), notE.fromNullable('error', value).foldrequires both branches. Forgetting the error branch is a compile error — that is the point, but expect friction when refactoring legacy code.- Don't mix
ApplysequenceS withchainfor validation.sequenceSruns all validations and accumulates;chainshort-circuits. Choose deliberately. TE.orElseexpects a function returning TaskEither. Passing a raw value instead ofTE.right(value)will fail type checking.- Do not treat this skill as a substitute for environment-specific validation, testing, or expert review. Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
Verification
- Confirm
fp-tsis installed:
npm ls fp-ts
Expected: a version line such as fp-ts@2.x.x and no UNMET DEPENDENCY.
- Type-check a file using these patterns:
npx tsc --noEmit
Expected: zero errors. If chainW errors appear, verify error types are being widened correctly.
- Quick runtime smoke test for Either:
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
const result = pipe(
E.right(21),
E.map(n => n * 2),
E.fold(
(e) => `Failed: ${e}`,
(v) => `Got: ${v}`
)
)
console.log(result) // Got: 42
Expected output: Got: 42.
- Quick runtime smoke test for TaskEither:
import * as TE from 'fp-ts/TaskEither'
import { pipe } from 'fp-ts/function'
const run = pipe(
TE.right(10),
TE.map(n => n + 1),
TE.getOrElse(() => TE.right(0))
)
run().then(console.log) // Right(11)
Expected output: { _tag: 'Right', right: 11 }.
- Verify validation accumulation returns all errors:
// Reuse validateForm from Procedure step 5
console.log(validateForm({ email: '', password: '123', age: 15 }))
Expected: Left containing a NonEmptyArray with three error strings.
Related Skills
- Skills covering
fp-tsOptionfor nullable values without error semantics. - Skills covering
io-tsorzodruntime validation integrated withEither.