Skip to content

Task — Lazy Asynchronous Work

JavaScript Promise instances execute eagerly upon creation and can reject with untyped values (unknown). This makes it difficult to compose async workflows before execution or track errors in type signatures.

Task<A> is a zero-argument function returning an infallible Deferred<A>:

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

Because Task is a function, execution is deferred until invoked. Because Deferred<A> is typed to never reject, failures are tracked explicitly in return types via Task.Result<E, A>.

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

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

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

// 2. Explicitly execute by invoking the function
const timeString = await formattedTime();

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>

Because standard Promises in JavaScript are fallible by definition, Task.tryCatch is the canonical intake for operations that may reject or throw an exception, trapping errors with a fallback handler to ensure the returned Task<A> is infallible and never throws:

// Trapping errors with a fallback handler
const loadConfig = Task.tryCatch(
  () => configStore.get("default"),
  { onError: () => DEFAULT_CONFIG },
); // Task<Config>

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.tryCatch(
    () => preferencesService.get(userId),
    { onError: () => DEFAULT_PREFERENCES },
  );

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, returning a Deferred value that resolves to the output, and 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.

Task.Result<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 { Task } from "@nlozgachev/pipelined/core";

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

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

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

Cancellation propagation in Task.Result chains

Section titled “Cancellation propagation in Task.Result chains”

When sequencing multiple Task.Result steps, Task.Result.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): Task.Result<string, Report> =>
  pipe(
    Task.Result.tryCatch((sig) => initiateJob(reportId, sig), { onError: String }),
    Task.Result.chain((job) => Task.Result.tryCatch((sig) => checkJobStatus(job.id, sig), { onError: String })),
    Task.Result.chain((status) => Task.Result.tryCatch((sig) => downloadData(status.url, sig), { onError: 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.

Retrying fallible tasks with Task.Result.retry

Section titled “Retrying fallible tasks with Task.Result.retry”

When network operations or database queries suffer transient failures, Task.Result.retry automatically re-evaluates the task according to a RetryPolicy:

import { Duration, RetryPolicy } from "@nlozgachev/pipelined/types";

// Constant delay policy (3 attempts, 500ms apart)
const constantPolicy = RetryPolicy.constant({
  attempts: 3,
  delay: Duration.milliseconds(500),
});

// Exponential backoff policy with randomized jitter
const exponentialPolicy = RetryPolicy.exponential({
  attempts: 5,
  initial: Duration.milliseconds(100),
  factor: 2,
  jitter: true,
});

const resilientFetch = pipe(
  fetchProfile("123"),
  Task.Result.retry(exponentialPolicy),
);

If an attempt succeeds (Ok), execution completes immediately without further retries. If an attempt fails (Err), the policy calculates the delay before executing the next attempt, respecting any AbortSignal passed from the call site.


Because Task blueprints are lazy thunks, calling a task multiple times will re-trigger the underlying async computation every time.

Task.memoize (as well as Task.Result.memoize, Task.Maybe.memoize, and Task.Validation.memoize) creates a memoized task that executes the operation at most once on the first call and caches the resolved result for all subsequent calls:

const fetchToken = Task.memoize(
  Task.tryCatch(
    () => fetchAuthToken(),
    { onError: () => "ANONYMOUS_TOKEN" },
  )
);

// First invocation triggers the network request:
const token1 = await fetchToken();

// Subsequent invocations return the cached result instantly without re-fetching:
const token2 = await fetchToken();

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

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

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

When an asynchronous operation resolves to None (representing an absent resource or a failed lookup), you can recover gracefully by providing a fallback asynchronous operation using recover:

const loadConfig = (userId: string): Task.Maybe<Config> =>
  pipe(
    fetchUserConfig(userId),
    Task.Maybe.recover(() => fetchDefaultConfig())
  );

Task.Validation<E, A> represents an asynchronous validation check that accumulates multiple errors, equivalent to Task<Validation<E, A>>:

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

const checkUsernameUnique = (username: string): Task.Validation<string, string> =>
  Task.Validation.tryCatch(
    (signal) => db.users.isUnique(username, signal),
    { onError: (error) => `Username check failed: ${error}` },
  );

Because validations accumulate multiple errors instead of failing fast, we often need to transform or perform side effects on the collected errors mid-pipeline. You can transform the error list using mapError, or execute a side effect (such as writing to a warning logger) using tapError:

const validateProfile = pipe(
  checkUsernameUnique(username),
  Task.Validation.mapError((err) => `Validation: ${err}`),
  Task.Validation.tapError((errors) => {
    logger.warn(`Async validation failed with ${errors.length} errors`);
  })
);

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, Task.Result, and Task.Maybe.

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), // Task.Maybe<User>
  Task.Maybe.bindTo("user"),
  Task.Maybe.bind("prefs", ({ user }) => getPreferences(user.id)),
  Task.Maybe.bind("theme", ({ prefs }) => getTheme(prefs.themeId))
); // Task.Maybe({ user: User, prefs: Preferences, theme: Theme })

If any step fails (yielding None in Task.Maybe or Err in Task.Result), 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 the struct helper, which is available on Task.Result, Task.Maybe, and Task.Validation.

struct combines a record of async tasks into a single task 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.

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

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

Evaluates fields in parallel, resolving to Some record if all succeed, and returning None immediately if any task resolves to None:

const profileTask = Task.Maybe.struct({
  user: lookupUser(userId),
  preferences: loadPreferences(userId),
}); // Task.Maybe<{ user: User; preferences: Preferences }>

Evaluates validations in parallel and accumulates all validation errors across all failed branches, rather than short-circuiting:

const validationTask = Task.Validation.struct({
  username: checkUsernameUnique(username),
  email: checkEmailNotBlocked(email),
}); // Task.Validation<string, { username: string; email: string }>

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.to.Promise:

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

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

  • Composing lazy asynchronous workflows: In background workers and server endpoints, asynchronous pipelines often involve multiple sequential steps (such as acquiring auth tokens, querying profile data, fetching permission lists, and writing audit records). Native promises execute eagerly upon creation. Task encapsulates asynchronous operations as lazy descriptions, enabling modular pipeline composition and transformation before initiating network or I/O work.
  • Coordinated concurrency and fallback racing: When aggregating data across distributed backends or caching layers, applications need fine-grained concurrency control. Task provides structured combinators like Task.all for parallel fan-out, Task.race for racing primary against fallback sources, and Task.sequential for queue execution, all while preserving functional purity.
  • Compiler-enforced async error handling with Task.Result: Network calls and disk I/O naturally produce domain failures. Standard Promise.reject discards TypeScript type information, allowing unhandled rejections to bubble up. Task.Result pairs lazy asynchronous execution with typed errors, ensuring the outer task always resolves safely while forcing callers to handle specific failure variants.
  • Time-bound operations and scheduled polling (Task.timeout, Task.delay, Task.repeat): Wrapping external network calls with timeout boundaries typically involves race hackery with setTimeout and cleanup listeners. Task.timeout attaches strict duration limits returning typed Result errors on expiration, while Task.delay and Task.repeat configure scheduled interval polling cleanly.
  • Asynchronous optionality with Task.Maybe: When loading cached tokens or reading optional database settings asynchronously, missing entries are expected. Task.Maybe threads async execution and optional absence together, allowing callers to map, chain, and recover without nested promise-and-null checks.