Skip to content

Task — lazy async

Promises are JavaScript’s built-in tool for async work, but they have two quirks that make them hard to compose. They start immediately when created, so you can’t build a pipeline and hand it around before any work begins. And they can reject, leaking failure as an untyped exception that callers have no way to anticipate from the return type alone. Task<A> solves both.

Promises are eager. A Promise starts the moment it’s created:

const p = new Promise<void>((resolve) => setTimeout(resolve, 5000));
// the 5-second countdown is already running

You can’t build a pipeline of async steps and pass it around before any work begins — by the time you have the Promise in hand, the work is already underway.

Promises can reject. Failure leaks out as an untyped exception rather than as a typed value. This forces try/catch at every call site and makes it impossible to tell from a function’s return type whether it can fail.

A Task<A> is a zero-argument function that returns a Deferred<A>:

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

Deferred<A> is a minimal async value: it supports await but has no .catch(), .finally(), or chainable .then(). This reinforces the infallibility guarantee at the type level — there is simply no way to register a rejection handler on a Deferred.

This addresses both problems with Promises. The function wrapper makes it lazy — nothing runs until you call it. And by treating Tasks as always-succeeding computations, failure is pushed into the type: TaskResult<E, A> is Task<Result<E, A>>, so it’s impossible to overlook.

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

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

// Nothing has happened yet. getTimestamp is just a description.

const pipeline = pipe(
  getTimestamp,
  Task.map((ts) => new Date(ts).toISOString()),
);

// Still nothing. pipeline is a new Task<string>.

const result = await pipeline(); // NOW it runs

The pipeline is built first, then executed once by calling it. You can pass it around, compose it further, or call it multiple times.

Task.resolve(42); // Task that resolves to 42 immediately
Task.from(() => Promise.resolve(Date.now())); // Task from any Promise-returning function

Task.from is an explicit alias for writing () => somePromise(). It’s mainly useful for clarity:

const getTimestamp: Task<number> = Task.from(() => Promise.resolve(Date.now()));

map transforms the resolved value without running the Task:

pipe(
  Task.resolve(5),
  Task.map((n) => n * 2),
)(); // Promise resolving to 10

Chaining maps builds a description of the transformation; the actual async work happens when you call the result.

chain sequences two async operations where the second depends on the result of the first:

const readUserId: Task<string> = () => Promise.resolve(session.userId);

const loadPreferences =
  (userId: string): Task<Preferences> =>
  () =>
    Promise.resolve(prefsCache.get(userId));

const userPrefs: Task<Preferences> = pipe(
  readUserId,
  Task.chain(loadPreferences),
);

await userPrefs(); // reads user ID, then loads their preferences

Each step waits for the previous one to resolve before starting.

Task.all takes an array of Tasks and runs them simultaneously, collecting all results:

const [config, locale, theme] = await Task.all([
  loadConfig,
  detectLocale,
  () => loadTheme(userId),
])();

The return type is inferred from the input tuple — if you pass [Task<Config>, Task<string>], you get back Task<[Config, string]>.

Easy mix-up: all runs every task simultaneously. If tasks must not overlap or order matters, use sequential instead.

Task.race starts all Tasks simultaneously and resolves as soon as the first one completes. The others are abandoned — their results are ignored.

const primary  = Task.from(() => fetchFromPrimaryRegion(id));
const fallback = Task.from(() => fetchFromFallbackRegion(id));

const fastest = Task.race([primary, fallback]);
const data = await fastest(); // whichever responds first

This is useful for hedged requests: fire the same request at multiple endpoints and accept the first response, reducing tail latency.

Running Tasks one at a time with sequential

Section titled “Running Tasks one at a time with sequential”

When order matters or tasks must not overlap, Task.sequential runs each Task only after the previous one resolves, collecting all results in an array:

const steps = [
  Task.from(() => acquireLock(resourceId)),
  Task.from(() => processResource(resourceId)),
  Task.from(() => releaseLock(resourceId)),
];

const results = await Task.sequential(steps)();
// results[0] = lock handle, results[1] = processed value, results[2] = release confirmation

Task.sequential is the counterpart to Task.all: use all when tasks are independent and can overlap, and sequential when each step must complete before the next begins.

Task.delay adds a pause before the Task runs:

pipe(Task.resolve("ping"), Task.delay(1000))(); // resolves to "ping" after 1 second

Useful for debouncing or rate limiting.

repeat and repeatUntil run a Task multiple times unconditionally — useful for scheduled polling or heartbeats, not error recovery. This fits naturally with Task’s guarantee that it never fails.

Task.repeat runs a Task a fixed number of times and collects every result:

pipe(pollSensor, Task.repeat({ times: 5, delay: 1000 }))(); // Task<Reading[]> — 5 readings, one per second

Task.repeatUntil keeps running until the result satisfies a predicate, then returns it. This is the natural shape for polling:

pipe(
  checkDeploymentStatus,
  Task.repeatUntil({ when: (s) => s === "ready", delay: 2000 }),
)(); // checks every 2s until the deployment is ready

Both accept an optional delay (in ms) inserted between runs. The delay is not applied after the final run.

Task<A> is for async operations that always succeed. When failure is possible, use the specialised variants — for most async work that can fail, TaskResult is what you want:

TaskResult<E, A> — an async operation that can fail with a typed error. It’s Task<Result<E, A>> under the hood:

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

const fetchUser = (id: string): TaskResult<string, User> =>
  TaskResult.tryCatch(
    (signal) => fetch(`/users/${id}`, { signal }).then((r) => r.json()),
    (e) => `Fetch failed: ${e}`,
  );

const name = pipe(
  fetchUser("123"),
  TaskResult.map((user) => user.name),
  TaskResult.getOrElse(() => "Unknown"),
);

await name(); // "Alice" or "Unknown"

TaskResult.chain forwards the call-site signal to each inner step automatically. A single AbortController at the call site reaches whichever request is currently in flight — no extra wiring at each step:

const fetchReport = (id: string): TaskResult<string, Report> =>
  pipe(
    TaskResult.tryCatch(
      (signal) => fetch(`/reports/${id}/start`, { signal }).then((r) => r.json()),
      String,
    ),
    TaskResult.chain((step1) =>
      TaskResult.tryCatch(
        (signal) => fetch(step1.nextUrl, { signal }).then((r) => r.json()),
        String,
      )
    ),
    TaskResult.chain((step2) =>
      TaskResult.tryCatch(
        (signal) => fetch(step2.dataUrl, { signal }).then((r) => r.json()),
        String,
      )
    ),
  );

const controller = new AbortController();
const result = await fetchReport(id)(controller.signal);
// controller.abort() at any point cancels the step currently in flight
// and the chain stops — no subsequent requests are made

You can add as many chain steps as the transaction requires. The signal propagates through all of them without any extra wiring.

TaskMaybe<A> — an async operation that may return nothing. It’s Task<Maybe<A>>:

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

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

const displayName = pipe(
  findUser("123"),
  TaskMaybe.map((user) => user.name),
  TaskMaybe.getOrElse(() => "Guest"),
);

await displayName();

TaskMaybe.tryCatch catches any rejection and converts it to None — useful when you treat a failed lookup the same as a missing value.

TaskValidation<E, A> — an async operation that accumulates errors. Used for async validation where all checks should run regardless of individual failures.

TaskResult and TaskMaybe follow the same API conventions as their synchronous counterparts (map, chain, match, getOrElse, recover). TaskValidation mirrors Validationap instead of chain for error accumulation, plus product and productAll for combining independent async validations. If you’ve used Result, TaskResult will be immediately familiar.

A Task is just a function. To run it, call it — calling returns a Deferred<A>, which you can await directly:

const task: Task<number> = Task.resolve(42);
const result: number = await task();

For TaskResult and TaskMaybe, the result is a wrapped value:

const taskResult: TaskResult<string, number> = TaskResult.ok(42);
const result: Result<string, number> = await taskResult(); // Ok(42)

Most of the time you’ll call the pipeline at one point — the outer boundary where your application produces a final result or triggers a side effect.

When you need an explicit Promise<A> — for example, to pass to a third-party API that requires one — convert the Deferred with Deferred.toPromise:

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

const p: Promise<number> = Deferred.toPromise(task());

This is the only case where you need to reach for Deferred directly. Everywhere inside a pipe chain, await task() is all you need.

Use Task when:

  • You want to build a pipeline of async steps that you can compose, pass around, or delay before executing
  • You need parallel execution via Task.all, first-wins via Task.race, or ordered execution via Task.sequential
  • You want typed error handling with TaskResult instead of try/catch around async functions

Keep using async/await directly when:

  • The operation is a one-liner with no composition needed
  • You’re inside a function body and the imperative style is clearer
  • You’re working with code that isn’t pipeline-oriented

The two styles interoperate freely. Task.from(() => someAsyncFunction()) wraps any async function into a Task, and await task() integrates back into any async/await context — the Deferred that task() returns is thenable, so the runtime handles it exactly like a Promise.