| name | fp-async |
| description | Composes TypeScript async pipelines with fp-ts TaskEither: TE.tryCatch, chain vs map, Do/bind, sequenceT parallelism, orElse/retry, and typed API/DB/file errors. Use when wrapping Promises, replacing nested try/catch, or building TaskEither clients in fp-ts. Not for Effect-ts, RxJS, neverthrow, or plain async/await without fp-ts. Never TE.map a function that returns TaskEither (nested TE) or sequenceT dependent steps. |
| risk | unknown |
| source | community |
| version | 1.0.1 |
| tags | ["fp-ts","typescript","async","error-handling","practical","promises","api","fetch"] |
Practical Async Patterns with fp-ts
Stop writing nested try/catch blocks. Stop losing error context. Start building clean async pipelines that handle errors properly.
TaskEither is simply an async operation that tracks success or failure. That's it. No fancy terminology needed.
When to Use
- You need async error handling in TypeScript with
TaskEither.
- The task involves wrapping Promises, composing API calls, or replacing nested
try/catch flows.
- You want practical fp-ts async patterns instead of academic explanations.
- You are building API clients, database repositories, or file I/O layers that need typed errors.
- You need parallel vs sequential execution control with proper error propagation.
- You want retry, fallback, or conditional recovery patterns without callback hell.
Prerequisites
- TypeScript project with
fp-ts installed.
- Install if missing:
npm install fp-ts
- Familiarity with
Either and pipe from fp-ts is helpful but not required — all patterns are self-contained.
- Node.js 18+ recommended for native
fetch support in examples.
Procedure
1. Wrapping Promises Safely
The core idea: wrap once, handle cleanly everywhere.
The Problem — Try/Catch Everywhere:
async function getUserData(userId: string) {
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const user = await response.json()
try {
const posts = await fetch(`/api/users/${userId}/posts`)
if (!posts.ok) {
throw new Error(`HTTP ${posts.status}`)
}
const postsData = await posts.json()
return { user, posts: postsData }
} catch (postsError) {
console.error('Failed to fetch posts:', postsError)
return { user, posts: [] }
}
} catch (error) {
.(, error)
error
}
}
The Solution — Wrap Once, Handle Cleanly:
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
const fetchJson = <T>(url: string): TE.TaskEither<Error, T> =>
TE.tryCatch(
async () => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
},
(error) => error instanceof Error ? error : new Error(String(error))
)
const getUser = (userId: string) => fetchJson<User>(`/api/users/${userId}`)
const = () => fetchJson<[]>()
tryCatch Explained:
TE.tryCatch takes two things:
- An async function that might throw
- A function to convert the thrown value into your error type
TE.tryCatch(
() => somePromise,
(thrown) => toError(thrown)
)
Creating Success and Failure Values:
const success = TE.right<Error, number>(42)
const failure = TE.left<Error, number>(new Error('Nope'))
const fromNullable = TE.fromNullable(new Error('Value was null'))
const result = fromNullable(maybeUser)
const mustBePositive = TE.fromPredicate(
(n: number) => n > 0,
(n) => new Error(`Expected positive, got ${n}`)
)
2. Chaining Async Operations
The Problem — Nested Awaits:
async function processOrder(orderId: string) {
try {
const order = await fetchOrder(orderId)
if (!order) throw new Error('Order not found')
try {
const user = await fetchUser(order.userId)
if (!user) throw new Error('User not found')
try {
const inventory = await checkInventory(order.items)
if (!inventory.available) throw new Error('Out of stock')
try {
const payment = await chargePayment(user, order.total)
if (!payment.success) throw new Error('Payment failed')
try {
const shipment = await createShipment(order, user)
{ order, shipment, payment }
} (e) {
(payment.)
e
}
} (e) { e }
} (e) { e }
} (e) { e }
} (e) {
.(, e)
e
}
}
The Solution — Clean Pipelines with chain:
const processOrder = (orderId: string) =>
pipe(
fetchOrder(orderId),
TE.chain(order => fetchUser(order.userId)),
TE.chain(user =>
pipe(
checkInventory(order.items),
TE.chain(inventory => chargePayment(user, order.total))
)
),
TE.chain(payment => createShipment(order, user, payment))
)
chain vs map:
Use map when your transformation is synchronous and can't fail:
pipe(
fetchUser(userId),
TE.map(user => user.name.toUpperCase())
)
Use chain (or flatMap) when your transformation is async or can fail:
pipe(
fetchUser(userId),
TE.chain(user => fetchOrders(user.id))
)
Building Context with Do Notation:
When you need values from multiple steps:
const processOrderManual = (orderId: string) =>
pipe(
fetchOrder(orderId),
TE.chain(order =>
pipe(
fetchUser(order.userId),
TE.chain(user =>
pipe(
chargePayment(user, order.total),
TE.map(payment => ({ order, user, payment }))
)
)
)
)
)
const processOrder = (orderId: string) =>
pipe(
TE.Do,
TE.bind('order', () => fetchOrder(orderId)),
TE.bind('user', ({ order }) => fetchUser(order.userId)),
TE.bind(, (user, order.)),
.(, (order, user)),
.( ({
: order.,
: payment.,
: shipment.
}))
)
3. Parallel vs Sequential Execution
When to Use Each:
- Sequential (one after another): each operation depends on the previous result; you need to respect rate limits; order matters.
- Parallel (all at once): operations are independent; you want speed; fetching multiple resources by ID.
Sequential Chaining:
const getUserWithOrg = (userId: string) =>
pipe(
fetchUser(userId),
TE.chain(user => fetchTeam(user.teamId)),
TE.chain(team => fetchOrganization(team.orgId))
)
Parallel Execution:
import { sequenceT } from 'fp-ts/Apply'
const getDashboardData = (userId: string) =>
sequenceT(TE.ApplyPar)(
fetchUser(userId),
fetchNotifications(userId),
fetchRecentActivity(userId)
)
const getDashboard = (userId: string) =>
pipe(
sequenceT(TE.ApplyPar)(
fetchUser(userId),
fetchNotifications(userId),
fetchRecentActivity(userId)
),
TE.map(([user, notifications, activities]) => ({
user,
notifications,
activities,
unreadCount: notifications.filter(n => !n.read).length
}))
)
Parallel Array Operations:
const userIds = ['1', '2', '3', '4', '5']
const fetchAllUsers = pipe(
userIds,
TE.traverseArray(fetchUser)
)
Parallel with Batch Control:
When you need to limit concurrent requests:
const chunk = <T>(arr: T[], size: number): T[][] => {
const chunks: T[][] = []
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size))
}
return chunks
}
const fetchUsersWithLimit = (userIds: string[]) => {
const batches = chunk(userIds, 5)
return pipe(
batches,
TE.traverseArray(batch =>
pipe(batch, TE.traverseArray(fetchUser))
),
TE.map(results => results.flat())
)
}
Sequential When Parallel Looks Tempting:
const createUserAndProfile = (userData: UserData) =>
sequenceT(TE.ApplyPar)(
createUser(userData),
createProfile(userData.profile)
)
const createUserAndProfile = (userData: UserData) =>
pipe(
createUser(userData),
TE.chain(user =>
pipe(
createProfile(user.id, userData.profile),
TE.map(profile => ({ user, profile }))
)
)
)
4. Error Recovery Patterns
Fallback to Alternative:
const getUserWithFallback = (userId: string) =>
pipe(
fetchUserFromApi(userId),
TE.orElse(() => fetchUserFromCache(userId))
)
const getConfigRobust = () =>
pipe(
fetchRemoteConfig(),
TE.orElse(() => loadLocalConfig()),
TE.orElse(() => TE.right(defaultConfig))
)
Conditional Recovery:
const fetchUserOrCreate = (userId: string) =>
pipe(
fetchUser(userId),
TE.orElse(error =>
error.message.includes('404') || error.message.includes('not found')
? createDefaultUser(userId)
: TE.left(error)
)
)
Typed Error Recovery:
type ApiError =
| { _tag: 'NotFound'; id: string }
| { _tag: 'NetworkError'; cause: Error }
| { _tag: 'Unauthorized' }
const fetchUser = (id: string): TE.TaskEither<ApiError, User> =>
TE.tryCatch(
async () => {
const res = await fetch(`/api/users/${id}`)
if (res.status === 404) throw { _tag: 'NotFound', id }
if (res.status === 401) throw { _tag: 'Unauthorized' }
if (!res.ok) throw { _tag: 'NetworkError', cause: new Error(`HTTP ${res.status}`) }
return res.json()
},
(e): ApiError =>
typeof e === && e !== && e
? e
: { : , : e ? e : ((e)) }
)
= () =>
(
(userId),
.( {
(error.) {
:
.(())
:
.(error)
:
(userId)
}
})
)
Retry with Exponential Backoff:
import * as T from 'fp-ts/Task'
const wait = (ms: number): T.Task<void> =>
() => new Promise(resolve => setTimeout(resolve, ms))
const retry = <E, A>(
operation: TE.TaskEither<E, A>,
maxAttempts: number,
baseDelayMs: number = 1000
): TE.TaskEither<E, A> => {
const attempt = (remaining: number, delay: number): TE.TaskEither<E, A> =>
pipe(
operation,
TE.orElse(error =>
remaining <= 1
? TE.left(error)
: pipe(
TE.fromTask(wait(delay)),
TE.chain(() => attempt(remaining - 1, delay * 2))
)
)
)
(maxAttempts, baseDelayMs)
}
= () =>
((userId), , )
Default Values:
const getUsernameOrDefault = (userId: string) =>
pipe(
fetchUser(userId),
TE.map(user => user.name),
TE.getOrElse(() => T.of('Anonymous'))
)
const getUserWithDefault = (userId: string) =>
pipe(
fetchUser(userId),
TE.orElse(() => TE.right(defaultUser))
)
5. Real API Examples
Complete Fetch Wrapper:
interface ApiError {
code: string
message: string
status: number
details?: unknown
}
const createApiError = (
code: string,
message: string,
status: number,
details?: unknown
): ApiError => ({ code, message, status, details })
const request = <T>(
url: string,
options: RequestInit = {}
): TE.TaskEither<ApiError, T> =>
TE.tryCatch(
async () => {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw createApiError(
body. || ,
body. || response.,
response.,
body
)
}
(response. === ) {
T
}
response.()
},
(error): {
( error === && error !== && error) {
error
}
(
,
error ? error. : ,
)
}
)
api = {
: <T> request<T>(url),
: <T>
request<T>(url, {
: ,
: .(body)
}),
: <T>
request<T>(url, {
: ,
: .(body)
}),
:
request<>(url, { : }),
}
= () => api.<>()
= () => api.<>(, data)
= () => api.<>(, data)
= () => api.()
Database Operations (Prisma Example):
import { PrismaClient, Prisma } from '@prisma/client'
type DbError =
| { _tag: 'NotFound'; entity: string; id: string }
| { _tag: 'UniqueViolation'; field: string }
| { _tag: 'ConnectionError'; cause: unknown }
const prisma = new PrismaClient()
const wrapPrisma = <T>(
operation: () => Promise<T>
): TE.TaskEither<DbError, T> =>
TE.tryCatch(
operation,
(error): DbError => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
const field = (error.meta?.target as string[])?.join(', ') || 'unknown'
return { _tag: , field }
}
(error. === ) {
{ : , : , : }
}
}
{ : , : error }
}
)
userRepository = {
: (: ): .<, > =>
(
( prisma..({ : { id } })),
.(
user
? .(user)
: .({ : , : , id })
)
),
: (: ): .<, | > =>
( prisma..({ : { email } })),
: (: ): .<, > =>
( prisma..({ data })),
: (: , : ): .<, > =>
( prisma..({ : { id }, data })),
: (: ): .<, > =>
(
( prisma..({ : { id } })),
.( )
),
}
= () =>
(
userRepository.(input.),
.(
existing
? .({ : , : })
: .()
),
.( userRepository.(input))
)
File Operations (Node.js):
import * as fs from 'fs/promises'
import * as path from 'path'
type FileError =
| { _tag: 'NotFound'; path: string }
| { _tag: 'PermissionDenied'; path: string }
| { _tag: 'IoError'; cause: unknown }
const toFileError = (error: unknown, filePath: string): FileError => {
if (error instanceof Error) {
if ('code' in error) {
if (error.code === 'ENOENT') return { _tag: 'NotFound', path: filePath }
if (error.code === 'EACCES') return { _tag: 'PermissionDenied', path: filePath }
}
}
return { _tag: 'IoError', cause: error }
}
const readFile = (filePath: ): .<, > =>
.(
fs.(filePath, ),
(e, filePath)
)
writeFile = (: , : ): .<, > =>
.(
fs.(filePath, content, ),
(e, filePath)
)
readJson = <T>(: ): .< | { : ; : }, T> =>
(
(filePath),
.(
.(
.(.(content)),
(e): { : ; : } => ({ : , : e })
)
)
)
= () =>
(
readJson<>(),
.( readJson<>()),
.( T.(defaultConfig))
)
6. Handling Results
Pattern Matching with fold/match:
const displayResult = pipe(
fetchUser(userId),
TE.fold(
(error) => T.of(`Error: ${error.message}`),
(user) => T.of(`Welcome, ${user.name}!`)
)
)
const message = await displayResult()
Getting the Raw Either:
const result = await fetchUser(userId)()
if (E.isLeft(result)) {
console.error('Failed:', result.left)
} else {
console.log('User:', result.right)
}
In Express/Hono Handlers:
app.get('/users/:id', async (req, res) => {
const result = await pipe(
fetchUser(req.params.id),
TE.fold(
(error) => T.of({ status: 500, body: { error: error.message } }),
(user) => T.of({ status: 200, body: user })
)
)()
res.status(result.status).json(result.body)
})
const sendResult = <E, A>(
res: Response,
te: TE.TaskEither<E, A>,
errorStatus: number = 500
) =>
pipe(
te,
TE.fold(
(error) => T.of(res.status(errorStatus).json({ error })),
(data) => T.of(res.(data))
)
)()
app.(, (req, res) => {
(res, (req..), )
})
7. Common Patterns Reference
Quick Transformations:
TE.map(user => user.name)
TE.mapLeft(error => ({ ...error, timestamp: Date.now() }))
TE.bimap(
error => enhanceError(error),
user => user.profile
)
Filtering:
pipe(
fetchUser(userId),
TE.filterOrElse(
user => user.isActive,
user => new Error(`User ${user.id} is not active`)
)
)
Side Effects Without Changing Value:
pipe(
fetchUser(userId),
TE.tap(user => TE.fromIO(() => console.log(`Fetched user: ${user.id}`)))
)
pipe(
fetchUser(userId),
TE.tapError(error => TE.fromIO(() => console.error(`Failed: ${error.message}`)))
)
pipe(
createUser(userData),
TE.chainFirst(user => sendWelcomeEmail(user.email))
)
Converting From Other Types:
const fromEither = TE.fromEither(E.right(42))
import * as O from 'fp-ts/Option'
const fromOption = TE.fromOption(() => new Error('Value was None'))
const result = fromOption(O.some(42))
const fromBoolean = TE.fromPredicate(
(x: number) => x > 0,
() => new Error('Must be positive')
)
Pitfalls
- Fails fast on parallel array operations:
TE.traverseArray fails on the first error — all subsequent errors are lost. If you need all errors collected, use a different strategy (e.g., TE.traverseArray with getApplicativeComposition or collect errors manually).
- Race conditions with
sequenceT(TE.ApplyPar): Running dependent operations in parallel causes race conditions. If operation B needs a value from operation A, use TE.chain (sequential), not sequenceT.
map vs chain confusion: TE.map is for synchronous transformations that cannot fail. TE.chain is for async operations or transformations that can fail. Using map with a function that returns TaskEither produces TaskEither<E, TaskEither<E, A>> — a nested type you almost never want.
getOrElse removes the error channel: After TE.getOrElse, you get a Task<A>, not a TaskEither<E, A>. You can no longer handle errors downstream. Use orElse if you want to keep the error channel.
fold returns a Task, not TaskEither: After fold, the error channel is consumed. You must provide both success and failure handlers.
- Error type widening: If you mix different error types in a pipeline (e.g.,
FileError and ApiError), TypeScript will infer a union. This can make error handling harder downstream. Consider a unified error type or use mapLeft to normalize.
tryCatch error conversion is untyped: The second argument to TE.tryCatch receives unknown. Always narrow and convert to a proper error type — never assume it's an Error instance.
- Do notation requires
fp-ts extensions: TE.Do and TE.bind are available in fp-ts 2.x. Ensure your version supports them.
traverseArray is on the module directly: In some versions, you may need (pipeable) vs . Check your version's API.
Verification
Verify your setup compiles and patterns work correctly:
- Check fp-ts is installed:
npm ls fp-ts
Expected output should show an installed version, e.g., fp-ts@2.16.0.
- Type-check your pipeline:
npx tsc --noEmit
No errors expected if types are correct.
- Quick smoke test — wrap and execute a TaskEither:
import * as TE from 'fp-ts/TaskEither'
import * as E from 'fp-ts/Either'
const test = TE.tryCatch(
async () => 42,
(e) => e instanceof Error ? e : new Error(String(e))
)
const result = await test()
console.log(E.isRight(result))
console.log(E.isLeft(result))
- Verify error path:
const fail = TE.tryCatch(
async () => { throw new Error('boom') },
(e) => e instanceof Error ? e : new Error(String(e))
)
const failResult = await fail()
console.log(E.isLeft(failResult))
console.log(E.isRight(failResult))
- Verify parallel execution returns tuple:
import { sequenceT } from 'fp-ts/Apply'
const parallel = sequenceT(TE.ApplyPar)(
TE.right(1),
TE.right('hello'),
TE.right(true)
)
const parResult = await parallel()
console.log(E.isRight(parResult))
Quick Reference Card
| What you want | How to do it |
|---|
| Wrap a promise | TE.tryCatch(() => promise, toError) |
| Create success | TE.right(value) |
| Create failure | TE.left(error) |
| Transform value | TE.map(fn) |
| Transform error | TE.mapLeft(fn) |
| Chain async ops | TE.chain(fn) or TE.flatMap(fn) |
| Run in parallel | sequenceT(TE.ApplyPar)(te1, te2, te3) |
| Array in parallel | TE.traverseArray(fn)(items) |
| Recover from error | TE.orElse(fn) |
| Use default value | TE.getOrElse(() => T.of(default)) |
| Handle both cases | TE.fold(onError, onSuccess) |
| Build up context | TE.Do + TE.bind('name', () => te) |
| Log without changing | TE.tap(fn) |
| Filter with error | TE.filterOrElse(pred, toError) |
Related Skills
- fp-either — Synchronous
Either patterns, the foundation of TaskEither.
- fp-option —
Option type for nullable values without exceptions.
- fp-pipe —
pipe and flow composition for building readable pipelines.
Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output 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.