Skip to content

Task — Lazy Asynchronous Work

In modern JavaScript, asynchronous operations are modeled using Promise objects. Promises are highly convenient runtime constructs, but they have two specific design characteristics that make them challenging to compose cleanly:

  1. Promises are eager: The moment a Promise is created, the underlying work starts executing.
  2. Promises are fallible: A Promise can reject, throwing an untyped exception that bypasses the static type system.

Because a Promise is eager, you cannot build a pipeline of asynchronous transformations and pass it around as a pure, inactive description before any work actually begins. By the time you have the Promise in hand, the network request is already in flight.

// Eager execution: the timer starts immediately
const delayPromise = new Promise<void>((resolve) => setTimeout(resolve, 5000));

Because Promises can reject, failure leaks out as an untyped runtime exception. This forces you to write defensive try/catch blocks at call sites, and makes it impossible to tell from a function’s type signature whether it can fail.

Task<A> solves both problems. A Task is simply a zero-argument function that returns a Deferred<A>:

type Task<A> = () => Deferred<A>;

Deferred<A> is a minimal, infallible asynchronous value. It supports await but has no .catch() or .finally() methods. At the type level, a Deferred value is guaranteed never to reject.

By wrapping our async work in a function, we make it lazy—nothing runs until you explicitly invoke the Task. And by treating the core Task as infallible, we force all possible failures into the data channel: TaskResult<E, A> is a Task yielding a Result<E, A>, making failures impossible to overlook.

import { Task } from "@nlozgachev/pipelined/core";
import { pipe } from "@nlozgachev/pipelined/composition";

const getTimestamp = Task.resolve(Date.now()); // Task<number>

// 1. Describe the pipeline — nothing executes
const formattedTime = pipe(
  getTimestamp,
  Task.map((ts) => new Date(ts).toISOString()),
); // Task<string>

// 2. Explicitly execute the pipeline at the edge of your app
const timeString = await formattedTime(); // The work starts here

We lift asynchronous values into a Task context depending on whether they are already resolved or require evaluation:

// Resolving immediately
const constantTask = Task.resolve(42); // Task<number>

// Wrapping a Promise-returning function
const fetchTimestamp = Task.from(() => Promise.resolve(Date.now())); // Task<number>

Task.from is a clear, explicit alias for wrapping a promise factory () => Promise<A>. It serves as the primary gateway for lifting standard async library calls into our functional system.


We can transform and chain our asynchronous blueprints while they are still in their lazy, unexecuted state.

map describes how the resolved value should change once the Task eventually runs, returning a new Task without triggering any execution:

const double = (n: number) => n * 2;

const doubledTask = pipe(
  Task.resolve(5),
  Task.map(double),
); // Task<number>

const result = await doubledTask(); // 10

When a transformation itself returns another Task, we use chain to execute them sequentially and flatten the resulting context:

const getSessionUserId = (): Task<string> => Task.resolve("user_abc");

const fetchUserPreferences = (userId: string): Task<Preferences> =>
  Task.from(() => preferencesService.get(userId));

const userPrefs = pipe(
  getSessionUserId(),
  Task.chain(fetchUserPreferences),
); // Task<Preferences>

const prefs = await userPrefs(); // Fetches userId first, then fetches preferences

Unlike raw Promises, which require manual coordination using Promise.all or Promise.race, Task provides clean, functional combinators for parallel, raced, or sequential execution.

Task.all runs an array of Tasks simultaneously and collects all results into a typed tuple:

const [config, user] = await Task.all([
  loadConfigTask,
  fetchUserTask,
])();

The return type is structurally matched to your input: passing [Task<Config>, Task<User>] yields a Task<[Config, User]> tuple.

Task.race starts multiple Tasks simultaneously and resolves with the outcome of whichever Task completes first, abandoning the remaining in-flight tasks:

const fastestFetch = Task.race([
  fetchFromPrimaryRegion,
  fetchFromSecondaryRegion,
]);

const data = await fastestFetch(); // Whichever region responds first

When execution order matters, or when running tasks in parallel would trigger race conditions or overload resources, Task.sequential executes each Task in submission order:

const results = await Task.sequential([
  () => acquireLock(resourceId),
  () => processPayload(resourceId),
  () => releaseLock(resourceId),
])();

Task.delay introduces a timed pause before the Task executes:

const delayedGreeting = pipe(
  Task.resolve("Hello"),
  Task.delay(1000), // Delays for 1 second
);

Task.abortable wraps a promise factory, yielding a managed Task alongside a shared abort function. Invoking abort() immediately cancels any active, in-flight execution:

const { task: searchIndex, abort } = Task.abortable(
  (signal) => fetchSearchResults(query, signal),
);

// If user types rapidly:
input.addEventListener("input", async () => {
  abort(); // Cancel the active search request
  const results = await searchIndex(); // Start a fresh search
});

Calling task() while a previous invocation is still active will automatically abort the previous run.

When composing inline pipelines, Task.run allows you to execute the Task at the terminal end of the pipe chain, accepting an optional AbortSignal for external cancellation:

const controller = new AbortController();

const configUrl = await pipe(
  loadConfigTask,
  Task.map((cfg) => cfg.apiUrl),
  Task.run(controller.signal),
);

Task provides built-in utilities to repeat executing a task, designed naturally around the guarantee that Tasks are infallible.

repeat executes a Task a fixed number of times, collecting all outcomes:

const readings = await pipe(
  readSensorTask,
  Task.repeat({ times: 5, delay: 1000 }), // Reads 5 times, waiting 1s between reads
)();

repeatUntil polls a Task repeatedly on an interval until the returned value satisfies a predicate:

const readyState = await pipe(
  checkStatusTask,
  Task.repeatUntil({
    when: (status) => status === "COMPLETED",
    delay: 2000,
    maxAttempts: 10, // Avoid infinite loops
  }),
)();

While Task<A> is excellent for operations that never fail, typical real-world async tasks involve network or database requests that can yield errors. For these, we use specialized variants within the Task family.

TaskResult<E, A> represents a fallible async task, equivalent to Task<Result<E, A>> under the hood. It serves as the primary tool for most asynchronous applications:

import { TaskResult } from "@nlozgachev/pipelined/core";

const fetchProfile = (userId: string): TaskResult<string, User> =>
  TaskResult.tryCatch(
    (signal) => fetch(`/users/${userId}`, { signal }).then((r) => r.json()),
    (error) => `Could not fetch user: ${error}`,
  );

const userDisplayName = pipe(
  fetchProfile("123"),
  TaskResult.map((user) => user.name),
  TaskResult.getOrElse(() => "Guest User"),
);

const name = await userDisplayName(); // Returns a string safely (never throws)

Cancellation propagation in TaskResult chains

Section titled “Cancellation propagation in TaskResult chains”

When sequencing multiple TaskResult steps, TaskResult.chain propagates the abort signal down the line automatically. An abort triggered at the call site immediately cancels whichever step is currently in flight:

const fetchReport = (reportId: string): TaskResult<string, Report> =>
  pipe(
    TaskResult.tryCatch((sig) => initiateJob(reportId, sig), String),
    TaskResult.chain((job) => TaskResult.tryCatch((sig) => checkJobStatus(job.id, sig), String)),
    TaskResult.chain((status) => TaskResult.tryCatch((sig) => downloadData(status.url, sig), String)),
  );

const controller = new AbortController();
const result = await fetchReport("report_42")(controller.signal);

// Invoking controller.abort() at any point will cancel the active request
// and instantly stop the chain, preventing subsequent network calls.

TaskMaybe<A> represents an asynchronous operation that may yield nothing, equivalent to Task<Maybe<A>>:

import { TaskMaybe } from "@nlozgachev/pipelined/core";

const lookupUser = (id: string): TaskMaybe<User> =>
  TaskMaybe.tryCatch(() => db.users.findById(id));

When you need to perform multiple sequential asynchronous operations and gather their results into a single object, nesting chain and map inside pipelines can become highly complex and hard to read:

const userProfile = pipe(
  getUser(userId),
  Task.chain((user) =>
    pipe(
      getPreferences(user.id),
      Task.map((prefs) => ({ user, prefs }))
    )
  ),
  Task.chain(({ user, prefs }) =>
    pipe(
      getTheme(prefs.themeId),
      Task.map((theme) => ({ user, prefs, theme }))
    )
  )
);

To solve this, you can use bindTo and bind to cleanly accumulate asynchronous values key-by-key in a flat, readable pipeline. These helpers are available across the entire Task family: Task, TaskResult, and TaskMaybe.

bindTo lifts an asynchronous value into the pipeline’s accumulator object:

pipe(
  Task.resolve(42),
  Task.bindTo("value")
); // Task({ value: 42 })

bind runs a new asynchronous operation using the accumulated object and attaches the result to a new key:

const userProfile = pipe(
  getUser(userId), // TaskMaybe<User>
  TaskMaybe.bindTo("user"),
  TaskMaybe.bind("prefs", ({ user }) => getPreferences(user.id)),
  TaskMaybe.bind("theme", ({ prefs }) => getTheme(prefs.themeId))
); // TaskMaybe({ user: User, prefs: Preferences, theme: Theme })

If any step fails (yielding None in TaskMaybe or Err in TaskResult), the entire pipeline short-circuits and propagates the failure immediately.


While bind is perfect for sequential steps where a latter step depends on the output of a prior step, sometimes you have a set of independent asynchronous tasks that you want to combine into a single object. For this, you can use TaskResult.struct.

It combines a record of TaskResults into a single TaskResult holding a record of success values. Under the hood, it evaluates all tasks in parallel (by calling them concurrently and using Promise.all internally) while correctly forwarding the AbortSignal down to each sub-task:

const profileTask = TaskResult.struct({
  user: getUser(userId),
  permissions: getUserPermissions(userId),
  status: TaskResult.ok("active"),
}); // TaskResult<string, { user: User; permissions: Permission[]; status: string }>

If any individual field resolves to an Err, the entire struct short-circuits to that error. If multiple fields fail, TaskResult.struct returns the first error encountered in key order:

const failed = TaskResult.struct({
  a: TaskResult.ok(1),
  b: TaskResult.err("first fail"),
  c: TaskResult.err("second fail"),
}); // Resolves to Err("first fail")

Because a Task is simply a function, you run it by invoking it. Invoking a Task<A> yields a Deferred<A>, which is highly compatible with the standard Promise ecosystem. You can await it directly within any standard async/await block:

const value = await fetchProfile("123")(); // Ok(user) or Err(error)

If you are interfacing with external libraries or frameworks that strictly require a standard Promise instance, you can convert the Deferred value using Deferred.toPromise:

import { Deferred } from "@nlozgachev/pipelined/core";

const profilePromise = Deferred.toPromise(fetchProfile("123")()); // Promise<Result<string, User>>

  • You need composition: You want to define, combine, or delay asynchronous steps point-free inside pipe chains before triggering execution.
  • You need concurrency controls: You require parallel, raced, or ordered sequential queues (Task.all, Task.race, Task.sequential).
  • You want typed errors: You want compiler-enforced error handling via TaskResult rather than catching untyped exceptions in standard async/await blocks.
  • Operations are standalone: Simple, linear scripts or internal functions where functional composition and pipelining add zero architectural value.