Skip to content

TaskEither

TaskEither is the most important async type in the library. It represents a lazy asynchronous computation that can either succeed with a value of type A or fail with a typed error E. Unlike throwing exceptions, the error type is explicit in the signature and tracked by the compiler.

type TaskEither<E, A> = Task<Either<E, A>>
// which expands to:
// () => Promise<Either<E, A>>

A function that, when called, returns a Promise that always resolves (never rejects) to an Either — a Left<E> on failure or a Right<A> on success.

import * as TE from "@oofp/core/task-either"

Lifts a value into a successful TaskEither. right is an alias for of.

const of: <E, A>(a: A) => TaskEither<E, A>
const task = TE.of(42) // TaskEither<never, number>

Creates a TaskEither that represents a failure.

const left: <E, A>(e: E) => TaskEither<E, A>
const task = TE.left("not found") // TaskEither<string, never>

Lifts a synchronous Either into a TaskEither.

const fromEither: <E, A>(either: Either<E, A>) => TaskEither<E, A>
import * as E from "@oofp/core/either"
const task = TE.fromEither(E.right(42)) // TaskEither<never, number>

Wraps a Task into a TaskEither, catching any thrown errors as Error.

const fromTask: <A>(task: Task<A>) => TaskEither<Error, A>
import * as T from "@oofp/core/task"
const task = TE.fromTask(T.of(42)) // TaskEither<Error, number>

Wraps a thunk returning a Promise into a TaskEither, catching rejections as Error.

const fromPromise: <A>(fn: () => Promise<A>) => TaskEither<Error, A>
const task = TE.fromPromise(() => fetch("/api/data").then((r) => r.json()))

The most common way to wrap fallible async operations. You provide a function to map the caught error into your typed error E.

const tryCatch: <E>(
onError: (e: unknown) => E
) => <A>(task: Task<A>) => TaskEither<E, A>
interface ApiError {
code: number
message: string
}
const fetchUser = (id: string): TE.TaskEither<ApiError, User> =>
pipe(
() => fetch(`/api/users/${id}`).then((r) => {
if (!r.ok) throw { code: r.status, message: r.statusText }
return r.json()
}),
TE.tryCatch((err) => err as ApiError),
)

Converts an async function into one that returns TaskEither<Error, R>. Rejections are automatically caught and wrapped in Error.

const taskify: <Args extends any[], R>(
fn: (...args: Args) => Promise<R>
) => (...args: Args) => TaskEither<Error, R>
const fetchUserTE = TE.taskify(
(id: string) => fetch(`/api/users/${id}`).then((r) => r.json())
)
const task = fetchUserTE("123") // TaskEither<Error, any> — not executed yet

Like taskify, but for functions that already return Promise<Either<E, R>>.

const taskifyEither: <Args extends any[], E, R>(
fn: (...args: Args) => Promise<Either<E, R>>
) => (...args: Args) => TaskEither<E, R>

Lift a Task into the right or left channel of a TaskEither.

const rightTask: <E, A>(ta: Task<A>) => TaskEither<E, A>
const leftTask: <E, A>(ta: Task<E>) => TaskEither<E, A>
import * as T from "@oofp/core/task"
const okTask = TE.rightTask<string, number>(T.of(42))
const errTask = TE.leftTask<string, number>(T.of("async error"))

Transforms the success value.

const map: <A, B>(f: (a: A) => B) => <E>(te: TaskEither<E, A>) => TaskEither<E, B>
const program = pipe(
fetchUser("123"),
TE.map((user) => user.name.toUpperCase()),
)

Transforms the error value.

const mapLeft: <E, E2>(f: (e: E) => E2) => <A>(te: TaskEither<E, A>) => TaskEither<E2, A>
const program = pipe(
fetchUser("123"),
TE.mapLeft((err) => ({ ...err, timestamp: Date.now() })),
)

Transforms both the error and the success value simultaneously.

const bimap: <E, A, E2, B>(
f: (e: E) => E2,
g: (a: A) => B
) => (te: TaskEither<E, A>) => TaskEither<E2, B>
const program = pipe(
fetchUser("123"),
TE.bimap(
(err) => `Error: ${err.message}`,
(user) => user.name,
),
)

Sequences two TaskEither computations. If the first succeeds, the result is passed to f. If it fails, the error short-circuits.

const chain: <E, A, B>(
f: (a: A) => TaskEither<E, B>
) => (te: TaskEither<E, A>) => TaskEither<E, B>
const getUser = (id: string): TE.TaskEither<ApiError, User> => /* ... */
const getOrders = (userId: string): TE.TaskEither<ApiError, Order[]> => /* ... */
const program = pipe(
getUser("123"),
TE.chain((user) => getOrders(user.id)),
)

Like chain, but widens the error type to the union E1 | E2. Use this when chaining operations with different error types.

const chainw: <E2, A, B>(
f: (a: A) => TaskEither<E2, B>
) => <E>(te: TaskEither<E, A>) => TaskEither<E | E2, B>
interface NotFoundError { kind: "not_found" }
interface ValidationError { kind: "validation"; field: string }
const getUser = (id: string): TE.TaskEither<NotFoundError, User> => /* ... */
const validateUser = (user: User): TE.TaskEither<ValidationError, ValidUser> => /* ... */
const program = pipe(
getUser("123"),
TE.chainw(validateUser),
)
// TaskEither<NotFoundError | ValidationError, ValidUser>

Chains on the error channel. If the TaskEither is a Left, the error is passed to f to attempt recovery.

const chainLeft: <E, A>(
f: (e: E) => TaskEither<E, A>
) => (te: TaskEither<E, A>) => TaskEither<E, A>
const program = pipe(
fetchFromPrimaryDB(id),
TE.chainLeft(() => fetchFromReplicaDB(id)), // fallback on error
)

Like chainLeft, but widens the error type.

const chainLeftw: <E2, E, A>(
f: (e: E) => TaskEither<E2, A>
) => (te: TaskEither<E, A>) => TaskEither<E | E2, A>

Chains a side-effectful TaskEither but keeps the original value. The chained operation must succeed for the pipeline to continue.

const tchain: <E, A>(
f: (a: A) => TaskEither<E, void>
) => (te: TaskEither<E, A>) => TaskEither<E, A>
const saveAuditLog = (user: User): TE.TaskEither<ApiError, void> => /* ... */
const program = pipe(
getUser("123"),
TE.tchain(saveAuditLog), // audit log fires, error propagates, but value is kept
TE.map((user) => user.name),
)

Side-effect operators let you perform actions without altering the pipeline value. OOFP provides several variants with different behaviors:

OperatorChannelAsync?Awaited?Error propagates?
tapRightNo--
tapLeftLeftNo--
tapTERightYesYesYes
tapTEAsyncRightYesNo (fire-and-forget)No
tapTEDetachedRightYesNo (fire-and-forget)Via callback
tapLeftTELeftYesYesYes
tapLeftTEAsyncLeftYesNo (fire-and-forget)No
tapLeftTEDetachedLeftYesNo (fire-and-forget)Via callback

Synchronous side effects on the success or error channel.

const program = pipe(
getUser("123"),
TE.tap((user) => console.log("Found user:", user.name)),
TE.tapLeft((err) => console.error("Failed:", err.message)),
)

Runs an async side effect on the success value. The side effect is awaited, and if it fails, its error propagates into the pipeline.

const notifySlack = (user: User): TE.TaskEither<SlackError, void> => /* ... */
const program = pipe(
getUser("123"),
TE.tapTE(notifySlack), // awaited; SlackError widens error type
)
// TaskEither<ApiError | SlackError, User>

Runs an async side effect but does not await it (fire-and-forget). Errors are silently swallowed.

const trackAnalytics = (user: User): TE.TaskEither<never, void> => /* ... */
const program = pipe(
getUser("123"),
TE.tapTEAsync(trackAnalytics), // fire and forget
)
// TaskEither<ApiError, User> — error type NOT widened

Like tapTEAsync, but accepts an optional error callback so you can handle errors from the detached operation (e.g., logging).

const program = pipe(
getUser("123"),
TE.tapTEDetached(
sendWelcomeEmail,
(err) => console.error("Email failed:", err),
),
)

tapLeftTE / tapLeftTEAsync / tapLeftTEDetached

Section titled “tapLeftTE / tapLeftTEAsync / tapLeftTEDetached”

Same three variants, but they run on the error channel instead.

const reportError = (err: ApiError): TE.TaskEither<never, void> =>
TE.fromPromise(() => fetch("/api/errors", {
method: "POST",
body: JSON.stringify(err),
}).then(() => {}))
const program = pipe(
getUser("123"),
TE.tapLeftTEAsync(reportError), // fire-and-forget error reporting
)

Eliminates the Either by handling both channels, returning a Task<B>.

const fold: <E, A, B>(
onLeft: (e: E) => B,
onRight: (a: A) => B
) => (te: TaskEither<E, A>) => Task<B>
import * as T from "@oofp/core/task"
const responseTask = pipe(
getUser("123"),
TE.fold(
(err) => ({ status: 500, body: err.message }),
(user) => ({ status: 200, body: user }),
),
)
const response = await T.run(responseTask)

Recovers from an error by providing an alternative TaskEither.

const orElse: <E, A, E2>(
f: (e: E) => TaskEither<E2, A>
) => (te: TaskEither<E, A>) => TaskEither<E2, A>
const program = pipe(
fetchFromCache(key),
TE.orElse(() => fetchFromDatabase(key)),
)

Extracts the success value, or computes a fallback from the error. Returns a Task<A>.

const getOrElse: <E, A>(f: (e: E) => A) => (te: TaskEither<E, A>) => Task<A>
const usernameTask = pipe(
getUser("123"),
TE.map((u) => u.name),
TE.getOrElse(() => "Anonymous"),
)
const name = await T.run(usernameTask) // string

Provides a fallback TaskEither that is tried if the first one fails.

const alt: <E, A>(
fallback: TaskEither<E, A>
) => (te: TaskEither<E, A>) => TaskEither<E, A>
const program = pipe(
fetchFromPrimary(id),
TE.alt(fetchFromSecondary(id)),
)

Conditional branching within a pipeline. Applies one of two functions based on a boolean condition.

const iif: <E, E2, A, B>(
condition: boolean,
onTrue: (a: A) => TaskEither<E2, B>,
onFalse: (a: A) => TaskEither<E2, B>
) => (te: TaskEither<E, A>) => TaskEither<E | E2, B>
const program = pipe(
getUser("123"),
TE.iif(
isAdmin,
(user) => getAdminDashboard(user),
(user) => getUserDashboard(user),
),
)

Retries a failing TaskEither with configurable max retries, delay between attempts, error callback, and a predicate to skip retries for certain errors.

type RetryOptions<E> = {
maxRetries: number
delay?: number
onError?: (e: E) => void
skipIf?: (e: E) => boolean
}
const retry: <E>(options: RetryOptions<E>) => <A>(te: TaskEither<E, A>) => TaskEither<E, A>
const fetchWithRetry = pipe(
TE.tryCatch((e) => e as Error)(
() => fetch("/api/flaky-endpoint").then((r) => r.json())
),
TE.retry({
maxRetries: 3,
delay: 1000,
onError: (err) => console.warn("Retrying due to:", err.message),
skipIf: (err) => err.message.includes("401"), // don't retry auth errors
}),
)

Converts TaskEither<E, A> to Task<E | A>, discarding the distinction between left and right.

const task = pipe(
TE.of<string, number>(42),
TE.toUnion,
)
// Task<string | number>

Converts TaskEither<E, A> to Task<A | null>. Errors become null.

const task = pipe(
getUser("123"),
TE.toNullable,
)
// Task<User | null>

Converts TaskEither<E, A> to Task<Maybe<A>>. Errors become Nothing.

const task = pipe(
getUser("123"),
TE.toMaybe,
)
// Task<Maybe<User>>

Converts TaskEither<E, A> to Task<A>. Left values become Promise rejections.

const task = pipe(
getUser("123"),
TE.toTask,
)
// Task<User> — may reject

Immediately executes the TaskEither and returns a Promise<A>. Left values become Promise rejections. This is the primary escape hatch for bridging to non-FP code.

// In an Express handler
app.get("/users/:id", async (req, res) => {
try {
const user = await TE.toPromise(getUser(req.params.id))
res.json(user)
} catch (err) {
res.status(500).json({ error: err })
}
})

Executes multiple TaskEither values sequentially and collects results into a typed tuple. Short-circuits on the first error.

const program = TE.sequence([
getUser("1"),
getUser("2"),
getUser("3"),
])
// TaskEither<ApiError, [User, User, User]>

Like sequence, but takes a record and returns a record of results.

const program = TE.sequenceObject({
user: getUser("123"),
orders: getOrders("123"),
settings: getSettings("123"),
})
// TaskEither<ApiError, { user: User; orders: Order[]; settings: Settings }>

Runs multiple TaskEither values with controlled concurrency and optional delay between batches.

const program = TE.concurrency({ concurrency: 2 })([
getUser("1"),
getUser("2"),
getUser("3"),
getUser("4"),
])

Like concurrency, but accepts a record of TaskEither values.

const program = TE.concurrencyObject({
user: getUser("123"),
orders: getOrders("123"),
})

Runs all TaskEither values concurrently and collects all results, regardless of success or failure — similar to Promise.allSettled.

const program = TE.concurrentSettled()([
getUser("1"),
getUser("2"),
getUser("3"),
])
// TaskEither<never, [Either<ApiError, User>, Either<ApiError, User>, Either<ApiError, User>]>

Applies a TaskEither containing a function to a TaskEither containing a value. Both run in parallel. applyw widens the error type.

const add = (a: number) => (b: number) => a + b
const result = pipe(
TE.of<string, number>(10),
TE.apply(pipe(TE.of<string, number>(5), TE.map(add))),
)
// TaskEither<string, number>

Sequential apply — runs the function first, then the argument. sapplyw widens the error type.

const result = pipe(
TE.of<string, number>(10),
TE.sapply(pipe(TE.of<string, number>(5), TE.map(add))),
)

Executes a TaskEither and returns the underlying Promise<Either<E, A>>.

const result = await TE.run(getUser("123"))
// Either<ApiError, User>

Flattens a nested TaskEither<E, TaskEither<E, A>>.

const nested: TE.TaskEither<string, TE.TaskEither<string, number>> =
TE.of(TE.of(42))
const flat = TE.join(nested) // TaskEither<string, number>

A pass-through operator. Returns the same TaskEither unchanged. Useful for conditional composition.

const program = pipe(
getUser("123"),
shouldLog ? TE.tap((u) => console.log(u)) : TE.identity(),
)

Delays execution by a given number of milliseconds before running the TaskEither.

const delayedFetch = pipe(
getUser("123"),
TE.delay(500), // wait 500ms before executing
)
import * as TE from "@oofp/core/task-either"
import { pipe } from "@oofp/core/pipe"
interface HttpError {
status: number
message: string
}
interface User {
id: string
name: string
email: string
}
const request = <A>(url: string): TE.TaskEither<HttpError, A> =>
pipe(
() => fetch(url).then(async (res) => {
if (!res.ok) throw { status: res.status, message: res.statusText }
return res.json() as Promise<A>
}),
TE.tryCatch((err) => err as HttpError),
)
const getUser = (id: string) => request<User>(`/api/users/${id}`)
const program = pipe(
getUser("123"),
TE.tap((user) => console.log(`Found: ${user.name}`)),
TE.map((user) => user.email),
TE.mapLeft((err) => `HTTP ${err.status}: ${err.message}`),
)
const result = await TE.run(program) // Either<string, string>

Resilient Pipeline with Retry and Fallback

Section titled “Resilient Pipeline with Retry and Fallback”
const fetchUserResilient = (id: string) =>
pipe(
getUser(id),
TE.retry({
maxRetries: 3,
delay: 2000,
onError: (err) => console.warn(`Attempt failed: ${err.message}`),
skipIf: (err) => err.status === 404, // no point retrying a 404
}),
TE.orElse((err) =>
err.status === 404
? TE.left(err) // propagate 404 as-is
: TE.of({ id, name: "Unknown", email: "" }) // fallback for other errors
),
)
const getUserProfile = (id: string) =>
TE.sequenceObject({
user: getUser(id),
orders: getOrders(id),
preferences: getPreferences(id),
})
// TaskEither<ApiError, { user: User; orders: Order[]; preferences: Preferences }>
const result = await TE.run(getUserProfile("123"))
// Express / Hono / etc.
app.get("/users/:id", async (req, res) => {
const result = await pipe(
getUser(req.params.id),
TE.fold(
(err) => ({ status: err.status, body: { error: err.message } }),
(user) => ({ status: 200, body: user }),
),
(task) => task(), // run the Task
)
res.status(result.status).json(result.body)
})