| name | fp-errors |
| description | Encodes TypeScript failures as fp-ts Either and TaskEither values: tryCatch at throw boundaries, chain and chainW pipelines, applicative field validation, and async orElse fallbacks. Use when callers need failure in the type, multi-field validation, or structured API error codes. Not a migration playbook for imperative modules (that is fp-refactor). Do not use for Rust Result, Python exceptions, or Zod-only schemas without fp-ts. |
| version | 1.0.1 |
| source | community |
| risk | unknown |
| tags | ["fp-ts","error-handling","either","task-either","typescript","validation","practical"] |
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
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.
- 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-ts installed.
- 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.
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
}
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)
const failure = E.left('Oops')
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}')
parseJSON('not json')
const safeParseJSON = E.tryCatchK(
JSON.parse,
(e) => (e instanceof Error ? e : new Error(String(e)))
)
3. Use Common Either Operations
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)
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> => { }
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.( (user, product))
)
)
)
processUserOrderDo = (: , : ): E.<, > =>
(
E.,
E.(, (userId)),
E.( user., ),
E.(, (productId)),
E.( product. >= , ),
E.( (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, > =>
age === ? E.(.())
: age < ? E.(.())
: E.(age)
= () =>
(validation)({
: (form.),
: (form.),
: (form.)
})
({ : , : , : })
({ : , : , : })
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. === field)?.
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)))
const result = await getUserPosts('123')()
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)
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 (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):
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 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.()
),
E.(,
.(obj.) && obj..( t === )
? E.(obj. [])
: E.()
),
E.( ({ 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. === ? : ,
response.
))
)
)
= () =>
(
fetchWithErrorHandling<>(),
.(
{
(error.) {
: T.(())
: T.(())
: T.((error.))
}
},
T.((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'])
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. === )
.( r.),
: results
.((r): r is { : ; : ; : } => r. === )
.( ({ 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.tryCatch at boundaries to wrap throwing code.
chain stops at the first error. If you need all errors (e.g., forms), use the validation applicative with sequenceS and NEA.getSemigroup, not chain.
chain requires identical error types. Use chainW when 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.fromNullable is curried. Call as E.fromNullable('error')(value), not E.fromNullable('error', value).
fold requires both branches. Forgetting the error branch is a compile error — that is the point, but expect friction when refactoring legacy code.
- Don't mix
Apply sequenceS with chain for validation. sequenceS runs all validations and accumulates; chain short-circuits. Choose deliberately.
TE.orElse expects a function returning TaskEither. Passing a raw value instead of TE.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-ts is 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)
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)
Expected output: { _tag: 'Right', right: 11 }.
- Verify validation accumulation returns all errors:
console.log(validateForm({ email: '', password: '123', age: 15 }))
Expected: Left containing a NonEmptyArray with three error strings.
Related Skills
- Skills covering
fp-ts Option for nullable values without error semantics.
- Skills covering
io-ts or zod runtime validation integrated with Either.