| name | fp-errors |
| description | Stop throwing everywhere - handle errors as values using Either and TaskEither for cleaner, more predictable code |
| risk | unknown |
| source | community |
| version | 1.0.0 |
| author | kadu |
| tags | ["fp-ts","error-handling","either","task-either","typescript","validation","practical"] |
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.
When to Use
- You need to replace exception-heavy code with
Either or TaskEither.
- The task involves validation, domain errors, or clearer error contracts in TypeScript.
- You want pragmatic fp-ts error-handling guidance for real application code.
1. Stop Throwing Everywhere
The Problem with Exceptions
Exceptions are invisible in your types. They break the contract between functions.
function getUser(id: string): User
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
}
const user = getUser(id)
You end up with code like this:
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'
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)
}
const result = getUser(id)
2. 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'
const success = E.right(42)
const failure = E.left('Oops')
if (E.isRight(result)) {
console.log(result.right)
} else {
console.log(result.left)
}
const message = pipe(
result,
E.fold(
(error) => `Failed: ${error}`,
(value) => `Got: ${value}`
)
)
Converting Throwing Code to Either
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}')
parseJSON('not json')
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'
const doubled = pipe(
E.right(21),
E.map(n => n * 2)
)
const betterError = pipe(
E.left('bad'),
E.mapLeft(e => `Error: ${e}`)
)
const value = pipe(
E.left('failed'),
E.getOrElse(() => 0)
)
const fromNullable = E.fromNullable('not found')
fromNullable(user)
3. 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
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'
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> => { ... }
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. >= ,
),
E.( (user, product))
)
)
)
processUserOrder = (: , : ): E.<, > =>
(
E.,
E.(, (userId)),
E.(
user.,
),
E.(, (productId)),
E.(
product. >= ,
),
E.( (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> => { ... }
const process = (id: string): E.Either<ValidationError | DbError, User> =>
pipe(
validateInput(id),
E.chainW(validId => fetchFromDb(validId))
)
4. Collecting Multiple Errors
Sometimes you want ALL errors, not just the first one. Form validation is the classic example.
Before: Collecting Errors Manually
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'
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, > =>
!password ? E.(.())
: password. < ? E.(.())
: E.(password)
validateAge = (: | ): E.<, > =>
age === ? E.(.())
: age < ? E.(.())
: E.(age)
= () =>
(validation)({
: (form.),
: (form.),
: (form.)
})
({ : , : , : })
({ : , : , : })
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>())
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)
const getFieldError = (errors: FormErrors, field: string): string |
errors.( e. === field)?.
5. 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'
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))
)
const result = await ()()
Before: Promise Chain with Error Handling
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) {
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(
sequenceS(TE.ApplyPar)({
posts: fetchPosts(user.id),
notifications: fetchNotifications(user.id),
settings: fetchSettings(user.id)
}),
TE.map(data => ({ user, ...data }))
)
)
)
pipe(
loadDashboard('123'),
TE.fold(
(error) => T.of(renderError(error)),
T.((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)
)
)
const fetchWithRetry = retry(fetchUser('123'), 3, 1000)
Fallback to Alternative
const getUserData = (id: string) =>
pipe(
fetchFromCache(id),
TE.orElse(() => fetchFromApi(id)),
TE.orElse(() => TE.right(defaultUser))
)
6. 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'
const user = users.find(u => u.id === id)
const result = E.fromNullable('User not found')(user)
const maybeUser: O.Option<User> = O.fromNullable(user)
const eitherUser = pipe(
maybeUser,
E.fromOption(() => 'User not found')
)
From Throwing Function to Either
const safeParse = <T>(schema: ZodSchema<T>) => (data: unknown): E.Either<ZodError, T> =>
E.tryCatch(
() => schema.parse(data),
(e) => e as ZodError
)
const parseUser = safeParse(UserSchema)
const result = parseUser(rawData)
From Promise to TaskEither
import * as TE from 'fp-ts/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)
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')
const either: E.Either<Error, User> = await myTaskEither()
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'))
const user = await (
(),
.( T.(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.()
),
E.(,
.(obj.) && obj..( t === )
? E.(obj. [])
: E.()
),
E.( ({ id, name, tags }))
)
({ : , : , : [, ] })
({ : , : , : })
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. === ? : ,
response.
))
)
)
= () =>
(
fetchWithErrorHandling<>(),
.(
{
(error.) {
: T.(())
: T.(())
: T.((error.))
}
},
T.((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 }>
}
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.) : O.)
)
}
}
= () =>
(inputs, {
n = (input, )
(n) ? E.() : E.(n)
})
([, , , ])
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' , result })
)
)
),
T.,
T.( ({
: results
.((r): r is { : ; : T } => r. === )
.( r.),
: results
.((r): r is { : ; : ; : } => r. === )
.( ({ id, error }))
}))
)
= () =>
(userIds,
(
(id),
.( e.)
)
)
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.
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for enprojectnment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.