Skip to content

Task — Lazy Asynchronous Work

4.04 kB minified + gzipped

JavaScript Promise instances execute eagerly upon creation and reject with untyped values (unknown):

// Eager execution starts immediately upon call
const response = loadUserData(userId); // Promise<User>

// Rejections are untyped — callers cannot determine error shapes from types
try {
  const user = await response;
} catch (error: unknown) {
  // error is untyped
}

Task<A> models an asynchronous computation as a lazy, infallible function that accepts an optional AbortSignal and returns a Deferred<A>:

type Task<A> = (signal?: AbortSignal) => Deferred<A>;

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

flowchart TB
    subgraph PromiseModel ["Native Promise (Eager Execution)"]
        P1["loadUserData(userId) called"] --> P2["Immediately in-flight on network"]
        P2 --> P3["Promise(User) settles (rejections untyped as unknown)"]
    end

    subgraph TaskModel ["Task (Lazy Execution Blueprint)"]
        T1["formattedTime pipeline defined"] --> T2["Inert blueprint (zero execution)"]
        T2 -->|"Explicit call: formattedTime(signal)"| T3["Executes on demand"]
        T3 --> T4["Deferred(A) (infallible resolution)"]
    end

    P3 ~~~ T1
import { pipe, Task } from "@pplnd/core";

const getTimestamp = Task.from.sync(() => 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();

Task values are constructed from immediate constants, lazy synchronous thunks, or fallible promise factories:

// Resolving immediately with a constant value
const constantTask = Task.make(42); // Task<number>

// Wrapping a lazy synchronous computation
const readClockTask = Task.from.sync(() => performance.now()); // Task<number>

For asynchronous operations that may throw or reject, Task.tryCatch traps errors with a fallback handler, guaranteeing the resulting Task<A> never rejects:

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

If the fallback handler returns an alternative sentinel type (such as null), TypeScript widens the return type automatically:

// Fallback returns alternative sentinel type
const loadOptionalConfig = Task.tryCatch(
  () => configStore.get("default"),
  { onError: () => null },
); // Task<Config | null>

Transforming, Sequencing, and Side Effects

Section titled “Transforming, Sequencing, and Side Effects”

Transformations, chains, and side-effect taps compose before execution starts.

map transforms the resolved value once the Task runs, returning a new Task without initiating execution:

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

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

const result = await doubledTask(); // 10

When a transformation returns another Task, chain sequences them in order and flattens the result:

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

const loadUserPreferences = (userId: string): Task<Preferences> =>
  Task.tryCatch(
    () => preferencesService.get(userId),
    { onError: () => DEFAULT_PREFERENCES },
  );

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

const prefs = await userPrefs(); // Loads userId first, then loads preferences

When a curried function is wrapped in a Task, Task.apply runs the function task and its argument tasks in parallel, applying the resolved arguments:

const formatSummary = (region: string) => (uptimeMs: number) =>
  `[${region}] Uptime: ${uptimeMs}ms`;

const detectRegionTask = Task.make("us-east-1");
const readUptimeTask = Task.from.sync(() => 14200);

const summaryTask = pipe(
  Task.make(formatSummary),
  Task.apply(detectRegionTask),
  Task.apply(readUptimeTask),
); // Task<string>

const summary = await summaryTask(); // "[us-east-1] Uptime: 14200ms"

Task.tap executes a side-effect callback on the resolved value without altering the Task output:

const auditedConfigTask = pipe(
  loadConfig,
  Task.tap((cfg) => console.info("Loaded config version:", cfg.version)),
);

Task provides composable combinators for parallel, raced, and sequential execution with automatic AbortSignal propagation.

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

const loadConfigTask = Task.make({ apiUrl: "https://api.example.com" });
const loadUserTask = Task.make({ id: "user_1", name: "Alice" });

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

The return type structurally matches the input: passing [Task<Config>, Task<User>] yields Task<[Config, User]>.

To limit concurrency, pass { concurrency }:

const userTasks = userIds.map((id) => loadUserTask(id));

// Executes at most 3 tasks concurrently using an internal worker pool:
const users = await Task.all(userTasks, { concurrency: 3 })();

Task.race starts multiple Tasks simultaneously and resolves with the outcome of whichever Task completes first, automatically aborting the remaining in-flight tasks via their AbortSignal:

const loadFromPrimaryCache = Task.delay(Duration.milliseconds(50))(Task.make("cache-hit"));
const loadFromSecondaryCache = Task.delay(Duration.milliseconds(10))(Task.make("replica-hit"));

const fastestSource = Task.race([
  loadFromPrimaryCache,
  loadFromSecondaryCache,
]);

const data = await fastestSource(); // "replica-hit"

Heterogeneous task types are preserved as a union: racing Task<number> and Task<string> evaluates to Task<number | string>.

When execution order matters, or when parallel execution would cause race conditions, Task.sequential executes tasks one at a time in submission order:

const results = await Task.sequential([
  acquireLockTask(resourceId),
  processPayloadTask(resourceId),
  releaseLockTask(resourceId),
])();

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

import { Duration } from "@pplnd/core";

const delayedGreeting = pipe(
  Task.make("Hello"),
  Task.delay(Duration.seconds(1)),
);

Task.timeout converts an infallible Task<A> into a fallible Task.Result<E, A>, resolving to Err(onTimeout()) if the task does not complete within the specified Duration and aborting the inner AbortSignal:

import { Duration } from "@pplnd/core";

const boundedComputation = pipe(
  runHeavyIndexRebuild,
  Task.timeout({
    duration: Duration.seconds(5),
    onTimeout: () => "INDEX_REBUILD_TIMED_OUT" as const,
  }),
); // Task.Result<"INDEX_REBUILD_TIMED_OUT", IndexStats>

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) => searchLocalIndex(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.

Progress tracking and diagnostic labels: withProgress and withLabel

Section titled “Progress tracking and diagnostic labels: withProgress and withLabel”

Task.withProgress invokes a callback with 0 immediately before the task starts and 1 when it resolves. Task.withLabel attaches a read-only .label property to a Task, preserving the literal label string in editor hover tooltips:

const backupTask = pipe(
  exportDatabaseSnapshot,
  Task.withProgress((ratio) => updateProgressBar(ratio * 100)),
  Task.withLabel("database-snapshot-backup"),
);

console.log(backupTask.label); // "database-snapshot-backup"

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 combinators for repeating and polling operations:

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

import { Duration } from "@pplnd/core";

const readings = await pipe(
  readSensorTask,
  Task.repeat({ times: 5, delay: Duration.seconds(1) }),
)();

poll re-runs a Task on an interval until the returned value satisfies a predicate, returning that final value:

const readyState = await pipe(
  checkStatusTask,
  Task.poll({
    until: (status) => status === "COMPLETED",
    delay: Duration.seconds(2),
    attempts: 10, // Cap attempts to avoid infinite loops
  }),
)();

Asynchronous network requests, disk operations, and external services can fail or return missing values. The Task namespace provides specialized container modules for these patterns:

  • Task.Result<E, A> for operations with typed errors.
  • Task.Maybe<A> for operations that may yield no value.
  • Task.Validation<E, A> for parallel operations that accumulate multiple errors.

Task.Result<E, A> is a type alias for Task<Result<E, A>>.

flowchart TB
    Input(["Task.Result.tryCatch(...)"]) --> Exec["Invoke Task: (signal) => Deferred"]
    Exec --> Railway{"Async Outcome"}
    Railway -->|"Async Success"| Ok["Result.Ok(A)"]
    Railway -->|"Async Catch / Trap"| Err["Result.Err(E)"]

    Ok -->|"Task.Result.chain / map"| Step2["Next Fallible Async Step"]
    Err -->|"Short-circuit (bypassed)"| ErrFinal["Propagated Result.Err(E)"]

    Step2 -->|"Success"| OkFinal["Result.Ok(B)"]
    Step2 -->|"Failure"| ErrFinal

Construct a Task.Result directly with make.ok and make.err, wrap a throwing or rejecting promise factory with tryCatch, or lift existing values via from.Result, from.Maybe, and from.nullable:

import { Maybe, Result, Task } from "@pplnd/core";

const okTask = Task.Result.make.ok(42);
const errTask = Task.Result.make.err("UNAUTHORIZED");

const fromNullableTask = pipe(
  process.env.API_URL,
  Task.Result.from.nullable(() => "Missing API_URL"),
);

const fromMaybeTask = pipe(
  Maybe.make.some("token_xyz"),
  Task.Result.from.Maybe(() => "Missing token"),
);

const fromResultTask = Task.Result.from.Result(Result.make.ok("ready"));

For network calls and fallible I/O, use Task.Result.tryCatch:

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}` },
  );

To discard the error channel and convert a Task.Result<E, A> into a Task.Maybe<A>, use Task.Result.to.Maybe:

const optionalProfile: Task.Maybe<User> = pipe(
  fetchProfile("123"),
  Task.Result.to.Maybe,
);

Transforming, recovering, and extracting Task.Result

Section titled “Transforming, recovering, and extracting Task.Result”

Task.Result mirrors the full Result combinator surface over async execution (map, mapError, bimap, chain, apply, ensure, tap, tapError, recover, recoverUnless, match, getOrElse, run):

const loadUserBanner = (userId: string): Task<string> =>
  pipe(
    fetchProfile(userId),
    Task.Result.ensure(
      (user) => user.isActive,
      (user) => `User ${user.id} is deactivated`,
    ),
    Task.Result.map((user) => user.name.trim()),
    Task.Result.mapError((err) => `[Auth] ${err}`),
    Task.Result.tap((name) => console.info(`Loaded active user: ${name}`)),
    Task.Result.tapError((err) => console.warn(err)),
    Task.Result.recoverUnless(
      (err) => err.includes("deactivated"),
      () => Task.Result.make.ok("Guest User"),
    ),
    Task.Result.match({
      ok: (name) => `Welcome, ${name}`,
      err: (err) => `Access denied (${err})`,
    }),
  );

When combining independent fallible tasks via a curried function, Task.Result.apply runs them in parallel:

const mergeUserAndQuota = (user: User) => (quota: number) => ({ user, quota });

const accountWithQuota = pipe(
  Task.Result.make.ok(mergeUserAndQuota),
  Task.Result.apply(fetchProfile("123")),
  Task.Result.apply(fetchQuota("123")),
);

When sequencing multiple Task.Result steps, Task.Result.chain propagates the AbortSignal down the line automatically. You can also enforce a deadline on any Task.Result using Task.Result.timeout, and execute the pipeline using Task.Result.run(signal):

import { Duration } from "@pplnd/core";

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) => downloadData(job.url, sig), { onError: String })
    ),
    Task.Result.timeout({
      duration: Duration.seconds(10),
      onTimeout: () => "Report generation timed out",
    }),
  );

const controller = new AbortController();
const result = await pipe(
  fetchReport("report_42"),
  Task.Result.run(controller.signal),
);

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 "@pplnd/core";

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

// Exponential backoff policy with randomized jitter and retry telemetry
const exponentialPolicy = RetryPolicy.exponential({
  attempts: 5,
  initial: Duration.milliseconds(100),
  factor: 2,
  jitter: true,
  onRetry: ({ attempt, error, delay }) => {
    console.warn(`Attempt ${attempt} failed with ${error}. Retrying in ${delay.millis}ms`);
  },
});

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

To fail fast on permanent client errors while retrying transient server errors, pass a when predicate to the policy constructor:

type HttpError = { status: number; message: string };

const isTransient = (err: unknown): boolean =>
  typeof err === "object" && err !== null && "status" in err && (err as HttpError).status >= 500;

const transientPolicy = RetryPolicy.exponential({
  attempts: 5,
  initial: Duration.milliseconds(100),
  when: isTransient,
});

const transientFetch = pipe(
  fetchProfile("123"),
  Task.Result.retry(transientPolicy),
);

Non-short-circuiting batching with allSettled

Section titled “Non-short-circuiting batching with allSettled”

When evaluating multiple independent fallible requests in parallel, Task.Result.struct short-circuits on the first error encountered. If you need all tasks to run to completion regardless of individual failures (such as health check probes or bulk batch processing), use Task.Result.allSettled:

import { Result } from "@pplnd/core";

const healthChecks = [
  checkDatabase,
  checkRedis,
  checkExternalGateway,
];

// Returns Task<readonly Result<CheckError, HealthStatus>[]>
const outcomes = await Task.Result.allSettled(healthChecks)();

for (const outcome of outcomes) {
  if (Result.is.ok(outcome)) {
    console.log("Service operational:", outcome.value);
  } else {
    console.error("Service degraded:", outcome.error);
  }
}

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

You can construct a Task.Maybe with Task.Maybe.make.some, Task.Maybe.make.none, Task.Maybe.tryCatch (which turns rejections into None), or lift values via Task.Maybe.from.Maybe, Task.Maybe.from.nullable, Task.Maybe.from.Result, and Task.Maybe.from.Task:

import { Task } from "@pplnd/core";

const lookupUser = (id: string): Task.Maybe<User> =>
  Task.Maybe.tryCatch((signal) =>
    fetch(`/users/${id}`, { signal }).then((r) => (r.ok ? r.json() : Promise.reject()))
  );

Task.Maybe supports map, chain, apply, filter, tap, recover, match, getOrElse, to.Result, and run:

const resolveActiveUserEmail = (userId: string): Task.Result<string, string> =>
  pipe(
    lookupUser(userId),
    Task.Maybe.filter((user) => user.isActive),
    Task.Maybe.map((user) => user.email),
    Task.Maybe.tap((email) => console.debug("Found active email:", email)),
    Task.Maybe.recover(() => lookupBackupEmail(userId)),
    Task.Maybe.to.Result(() => `No active email found for user ${userId}`),
  );

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

Create a Task.Validation using make.passed, make.failed, make.failedAll, tryCatch, or lift existing containers via from.Validation, from.Result, from.Maybe, and from.nullable:

import { Task } from "@pplnd/core";

const checkUsernameAvailable = (username: string): Task.Validation<string, string> =>
  Task.Validation.tryCatch(
    async (signal) => {
      const response = await fetch(`/api/usernames/${username}/available`, { signal });
      if (!response.ok) throw new Error("Username is already taken");
      return username;
    },
    { onError: () => `Username "${username}" is not available` },
  );

Task.Validation provides map, mapError, apply, struct, tap, tapError, recover, recoverUnless, match, getOrElse, to.Result, to.Maybe, and run:

const registerAccount = (username: string, email: string) =>
  pipe(
    Task.Validation.struct({
      username: checkUsernameAvailable(username),
      email: checkEmailDomainAllowed(email),
    }),
    Task.Validation.mapError((err) => `[Registration] ${err}`),
    Task.Validation.tapError((errors) => {
      logger.warn(`Async validation failed with ${errors.length} errors`);
    }),
    Task.Validation.to.Result((errors) => errors.join("; ")),
  );

Calling a Task executes the underlying asynchronous computation on every invocation.

Task.memoize (along with Task.Result.memoize, Task.Maybe.memoize, and Task.Validation.memoize) caches the resolved result on the first call, returning it immediately on subsequent calls without re-executing:

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

// First invocation triggers token resolution:
const token1 = await loadToken();

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

Sequential asynchronous operations that build up a combined record can be expressed with assignTo and assign without nested callbacks. Available across Task, Task.Result, and Task.Maybe:

assignTo wraps an asynchronous value into the pipeline’s accumulator object:

pipe(
  Task.make(42),
  Task.assignTo("value"),
); // Task<{ value: number }>

assign 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.assignTo("user"),
  Task.Maybe.assign("prefs", ({ user }) => getPreferences(user.id)),
  Task.Maybe.assign("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.


For independent asynchronous tasks that can execute in parallel, struct combines a record of tasks into a single task holding a record of success values. Available on Task.Result, Task.Maybe, and Task.Validation.

All fields evaluate in parallel, 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: checkUsernameAvailable(username),
  email: checkEmailDomainAllowed(email),
}); // Task.Validation<string, { username: string; email: string }>

Because a Task is a function, you run it by invoking it (or passing it to Task.run(signal)). Invoking a Task<A> yields a Deferred<A>, which you can await 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 "@pplnd/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, without mutating external state before execution.
  • Compiler-enforced async error handling with Task.Result: Network calls and disk I/O 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, Task.poll): Wrapping external network calls with timeout boundaries typically involves race boilerplate with setTimeout and cleanup listeners. Task.timeout and Task.Result.timeout attach strict duration limits returning typed Result errors on expiration, while Task.delay, Task.repeat, and Task.poll 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.