Task
A Task represents a lazy asynchronous computation that always succeeds. Unlike a raw Promise, a Task does not execute until you explicitly run it — giving you full control over when side effects happen and allowing you to compose pipelines before executing them.
type Task<A> = () => Promise<A>A Task<A> is simply a thunk that returns a Promise<A>. Because the function is not called until you decide, the computation is deferred.
Import
Section titled “Import”import * as T from "@oofp/core/task"Why lazy?
Section titled “Why lazy?”Promises in JavaScript are eager — they start executing the moment they are created. This makes composition difficult because side effects fire immediately. Task solves this by wrapping the Promise in a function:
import * as T from "@oofp/core/task"
// This does NOT make a network call yetconst fetchUsers: T.Task<User[]> = () => fetch("/api/users").then((r) => r.json())
// Nothing has happened. We can compose further before executing.API Reference
Section titled “API Reference”Constructors
Section titled “Constructors”Lifts a pure value into a Task.
const of: <A>(a: A) => Task<A>const task = T.of(42) // Task<number>// Equivalent to: () => Promise.resolve(42)rejected
Section titled “rejected”Creates a Task that will reject with the given error.
const rejected: <A>(e: Error | string) => Task<A>const failing = T.rejected<number>(new Error("something went wrong"))taskify
Section titled “taskify”Converts an async function into one that returns a Task instead of a Promise. Useful for wrapping existing async APIs.
const taskify: <Args extends any[], R>( fn: (...args: Args) => Promise<R>) => (...args: Args) => Task<R>const fetchJson = async (url: string) => { const res = await fetch(url) return res.json()}
const fetchJsonTask = T.taskify(fetchJson)
const task = fetchJsonTask("/api/users") // Task<any> — not executed yetTransformations
Section titled “Transformations”Transforms the value inside a Task.
const map: <A, B>(f: (a: A) => B) => (ta: Task<A>) => Task<B>import { pipe } from "@oofp/core/pipe"
const result = pipe( T.of(10), T.map((n) => n * 2), T.map((n) => `Value: ${n}`),)// Task<string> — still not executedSequences two Task computations where the second depends on the result of the first.
const chain: <A, B>(f: (a: A) => Task<B>) => (ta: Task<A>) => Task<B>const getUserName = (id: string): T.Task<string> => () => fetch(`/api/users/${id}`).then((r) => r.json()).then((u) => u.name)
const getGreeting = (name: string): T.Task<string> => T.of(`Hello, ${name}!`)
const program = pipe( T.of("user-123"), T.chain(getUserName), T.chain(getGreeting),)
await T.run(program) // "Hello, Alice!"tchain
Section titled “tchain”Like chain, but discards the result of the chained computation and keeps the original value. Useful for sequencing a side-effectful Task without losing the current value.
const tchain: <A>(f: (a: A) => Task<void>) => (ta: Task<A>) => Task<A>const logToAudit = (user: User): T.Task<void> => () => fetch("/api/audit", { method: "POST", body: JSON.stringify(user) }).then(() => {})
const program = pipe( fetchUser("123"), T.tchain(logToAudit), // audit log fires, but we keep the User T.map((user) => user.name),)Flattens a nested Task<Task<A>> into a Task<A>.
const join: <A>(tta: Task<Task<A>>) => Task<A>const nested: T.Task<T.Task<number>> = T.of(T.of(42))const flat: T.Task<number> = T.join(nested)Side Effects
Section titled “Side Effects”Executes a synchronous side effect on the resolved value without modifying it.
const tap: <A>(f: (a: A) => void) => (ta: Task<A>) => Task<A>const program = pipe( T.of(42), T.tap((n) => console.log("Got value:", n)), T.map((n) => n + 1),)tapRejected
Section titled “tapRejected”Executes a synchronous side effect when the Task rejects, without altering the rejection.
const tapRejected: <E>(f: (e: E) => void) => <A>(ta: Task<A>) => Task<A>const program = pipe( T.rejected<number>(new Error("fail")), T.tapRejected((err) => console.error("Task failed:", err)),)Timing
Section titled “Timing”Delays the resolution of a Task by a given number of milliseconds.
const delay: (ms: number) => <A>(ta: Task<A>) => Task<A>const delayed = pipe( T.of("done"), T.delay(1000), // waits 1 second after resolving)Error Handling
Section titled “Error Handling”Collapses a Task into a single Promise by handling both the rejection and resolution cases.
const fold: <A, R>( onRejected: (e: unknown) => R, onResolved: (a: A) => R) => (ta: Task<A>) => Promise<R>const result = await pipe( fetchData(), T.fold( (err) => ({ status: "error", message: String(err) }), (data) => ({ status: "ok", data }), ),)Applicative
Section titled “Applicative”Applies a Task containing a function to a Task containing a value. Both tasks run in parallel.
const apply: <A, B>(tf: Task<(a: A) => B>) => (ta: Task<A>) => Task<B>const add = (a: number) => (b: number) => a + b
const result = pipe( T.of(10), T.apply(pipe(T.of(5), T.map(add))),)
await T.run(result) // 15Execution
Section titled “Execution”Executes a Task and returns the underlying Promise.
const run: <A>(task: Task<A>) => Promise<A>const task = pipe( T.of(42), T.map((n) => n * 2),)
const value = await T.run(task) // 84Combining Tasks
Section titled “Combining Tasks”sequence
Section titled “sequence”Executes an array of Task values sequentially (one after another) and collects the results into a tuple.
const results = await T.run( T.sequence([ T.of(1), T.of("hello"), T.of(true), ]),)// [1, "hello", true] — fully typed as [number, string, boolean]sequenceObject
Section titled “sequenceObject”Like sequence, but takes a record of Task values and returns a record of results.
const results = await T.run( T.sequenceObject({ count: T.of(42), name: T.of("Alice"), active: T.of(true), }),)// { count: 42, name: "Alice", active: true }concurrency
Section titled “concurrency”Executes an array of Task values with controlled concurrency. Curried: config first, then array.
const tasks = [ () => fetch("/api/1").then((r) => r.json()), () => fetch("/api/2").then((r) => r.json()), () => fetch("/api/3").then((r) => r.json()), () => fetch("/api/4").then((r) => r.json()),]
const results = await T.run(T.concurrency({ concurrency: 2 })(tasks))concurrencyObject
Section titled “concurrencyObject”Like concurrency, but accepts a record of Task values. Also curried: config first, then object.
const results = await T.run( T.concurrencyObject()({ users: () => fetch("/api/users").then((r) => r.json()), posts: () => fetch("/api/posts").then((r) => r.json()), }),)// { users: [...], posts: [...] }Composition Example
Section titled “Composition Example”Build a complete pipeline that stays lazy until the very end:
import * as T from "@oofp/core/task"import { pipe } from "@oofp/core/pipe"
interface User { id: string name: string email: string}
const fetchUser = (id: string): T.Task<User> => () => fetch(`/api/users/${id}`).then((r) => r.json())
const sendWelcomeEmail = (email: string): T.Task<void> => () => fetch("/api/email", { method: "POST", body: JSON.stringify({ to: email, template: "welcome" }), }).then(() => {})
const program = pipe( fetchUser("user-42"), T.tap((user) => console.log(`Processing ${user.name}`)), T.tchain((user) => sendWelcomeEmail(user.email)), T.map((user) => ({ message: `Welcome email sent to ${user.name}` })), T.delay(100),)
// Nothing has executed yet. Run at the application boundary:const result = await T.run(program)// { message: "Welcome email sent to Alice" }When to Use Task vs TaskEither
Section titled “When to Use Task vs TaskEither”Task is appropriate when the computation cannot fail in a meaningful way, or when you don’t need typed error handling. For operations that can fail with structured errors (API calls, validation, database queries), use TaskEither instead.