Yieldless
Guides

Quickstart

Get a Yieldless application flow working with tuple errors, cancellation, and one realistic boundary.

The fastest way to understand Yieldless is to wire the core pieces together:

  1. yieldless/error to keep failures in tuple form
  2. yieldless/result when tuple branches need light composition
  3. yieldless/task, yieldless/all, yieldless/iterable, yieldless/queue, yieldless/pubsub, and yieldless/limiter to fan work out, hand it off, and control pressure
  4. yieldless/retry, yieldless/schedule, yieldless/signal, yieldless/timer, and yieldless/breaker for resilient async boundaries
  5. yieldless/cache, yieldless/batcher, and yieldless/singleflight for repeated, keyed, and duplicate work
  6. Boundary adapters like yieldless/fetch, yieldless/schema, yieldless/router, yieldless/ipc, yieldless/node, and yieldless/env

Install

pnpm add yieldless

TypeScript 5.5+ is the target baseline. The package is compiled with isolatedDeclarations enabled.

Step 1: stop throwing for routine failures

safeTry() and safeTrySync() let you treat common failures as data instead of exception control flow.

import { safeTry } from "yieldless/error";

const [repoError, repo] = await safeTry(loadRepository(repoId));

if (repoError) {
  return [repoError, null] as const;
}

That pattern is the center of the library. Every module is designed to fit back into the same [error, value] shape.

When a tuple flow grows past one branch, use yieldless/result to keep it readable.

import { fromNullable, mapOk } from "yieldless/result";

const userResult = fromNullable(
  repo.owner,
  () => new Error("Repository owner is missing"),
);

return mapOk(userResult, (owner) => ({
  id: repo.id,
  ownerName: owner.name,
}));

Keep this modest. If one if (error) branch is clearer, use the branch.

runTaskGroup() gives you a shared abort signal and sibling failure propagation.

import { runTaskGroup } from "yieldless/task";

const requestController = new AbortController();

const summary = await runTaskGroup(async (group) => {
  const refs = group.spawn((signal) => readRefs(repo.path, signal));
  const status = group.spawn((signal) => readStatus(repo.path, signal));

  return {
    refs: await refs,
    status: await status,
  };
}, {
  signal: requestController.signal,
});

If readRefs() throws, the group aborts the shared signal, waits for readStatus() to settle, and then rethrows the original failure. If the request or UI flow already has an AbortSignal, pass it in so the whole group inherits upstream cancellation.

When the work is a list rather than a few named tasks, use mapLimit() to keep pressure on the outside world under control.

import { mapLimit } from "yieldless/all";

const [thumbnailError, thumbnails] = await mapLimit(
  images,
  (image, _index, signal) => renderThumbnail(image, signal),
  { concurrency: 4, signal: requestController.signal },
);

if (thumbnailError) {
  return [thumbnailError, null] as const;
}

The result order matches the input order, and the first tuple error aborts the remaining in-flight work.

For async streams or generators, reach for yieldless/iterable instead of materializing everything first.

import { mapAsyncLimit } from "yieldless/iterable";

const [indexError, indexed] = await mapAsyncLimit(
  readRepositories(workspacePath),
  (repository, _index, signal) => indexRepository(repository, signal),
  { concurrency: 3, signal: requestController.signal },
);

Step 3: add retries where the outside world is unreliable

Retries should stay close to transport boundaries: HTTP, queues, database connections, and subprocesses that can legitimately fail for transient reasons.

import { safeTry } from "yieldless/error";
import { safeRetry } from "yieldless/retry";

const [fetchError, payload] = await safeRetry(
  async (_attempt, signal) => safeTry(fetchRepository(repoId, signal)),
  {
    maxAttempts: 4,
    baseDelayMs: 150,
  },
);

if (fetchError) {
  return [fetchError, null] as const;
}

The retry loop respects AbortSignal, so if a parent task group is canceled, the pending backoff timer is canceled too.

Step 4: keep the edges boring

When a boundary needs a firm time budget, derive one signal and pass it through the whole call chain.

import { safeTry } from "yieldless/error";
import { withTimeout } from "yieldless/signal";

const [fetchError, payload] = await safeTry(
  withTimeout(
    (signal) => fetchRepository(repoId, signal),
    { timeoutMs: 5_000 },
  ),
);

If you are calling HTTP JSON APIs, yieldless/fetch combines the same ideas:

import { fetchJsonSafe } from "yieldless/fetch";

const [error, user] = await fetchJsonSafe<User>(`/api/users/${userId}`, {
  timeoutMs: 5_000,
  signal: requestController.signal,
});

If you need to wait for eventual consistency, use poll() instead of hand-writing timers.

import { poll } from "yieldless/timer";

class JobNotReadyError extends Error {
  constructor() {
    super("Job is not ready yet.");
    this.name = "JobNotReadyError";
  }
}

const [jobError, job] = await poll(
  async (_attempt, signal) => {
    const [fetchError, current] = await fetchJsonSafe<Job>(jobUrl, { signal });

    if (fetchError) {
      return [fetchError, null] as const;
    }

    return current.status === "ready"
      ? [null, current] as const
      : [new JobNotReadyError(), null] as const;
  },
  {
    intervalMs: 1_000,
    timeoutMs: 30_000,
    signal: requestController.signal,
    shouldContinue: (error) => error instanceof JobNotReadyError,
  },
);

The package ships helpers for common backend boundaries and production pressure:

  • yieldless/env validates startup configuration
  • yieldless/fetch wraps HTTP status, JSON parsing, and deadlines
  • yieldless/schema keeps validators in tuple form
  • yieldless/router turns tuple handlers into HTTP responses
  • yieldless/ipc preserves tuple results across Electron IPC
  • yieldless/node wraps filesystem and child-process work
  • yieldless/event waits for one event with cleanup and abort support
  • yieldless/schedule builds reusable retry and repeat policies
  • yieldless/limiter protects shared capacity with semaphores and rate limits
  • yieldless/queue and yieldless/pubsub coordinate in-process workers and subscribers
  • yieldless/cache keeps successful read-through loads fresh for a TTL
  • yieldless/batcher collapses nearby keyed reads into ordered batches
  • yieldless/breaker backs off dependencies that are already failing
  • yieldless/singleflight deduplicates duplicate in-flight work
  • yieldless/test makes promises, clocks, and abort signals deterministic in tests

A full request flow

import { safeTry } from "yieldless/error";
import { safeRetry } from "yieldless/retry";
import { parseSafe } from "yieldless/schema";
import { NotFoundError, honoHandler } from "yieldless/router";

const getUser = honoHandler(async (c) => {
  const [inputError, input] = parseSafe(userParamsSchema, c.req.param());
  if (inputError) {
    return [inputError, null];
  }

  const [userError, user] = await safeRetry(
    async (_attempt, signal) => safeTry(loadUser(input.id, signal)),
    { maxAttempts: 3 },
  );

  if (userError) {
    return [userError, null];
  }

  if (user === null) {
    return [new NotFoundError("User not found"), null];
  }

  return [null, user];
});

Where to go next

  • Read Beginner Tutorial if the tuple style is still new and you want one feature built slowly.
  • Read Simple Recipes when you want small, single-feature snippets.
  • Read Read IDs and Fetch Records for a concrete forEach() plus fetchJsonSafe() workflow.
  • Read Examples when you want copy-pasteable small patterns and a few larger compositions.
  • Read Module Selection when you are deciding which helper fits a specific problem.
  • Read Design Rules before you spread the tuple style across a large codebase.
  • Read Do and Don't if you are moving a team onto the library and want the conventions to stay sharp.
  • Use the reference section when you already know what module you need.

Common mistakes

Good: keep tuple boundaries explicit

const [error, user] = await fetchJsonSafe<User>(url, { signal });
if (error) return [error, null] as const;

return [null, toUserView(user)] as const;

Avoid: carrying tuples through every layer

const result = await fetchJsonSafe<User>(url);
return renderProfile(result);

Most UI and domain code wants ordinary values or ordinary state. Convert at the boundary.

Good: pass cancellation into real I/O

await runCommandSafe("git", ["fetch"], {
  cwd: repoPath,
  signal,
});

Avoid: expecting Yieldless to stop work that ignores signals

await runTaskGroup(async (group) => {
  group.spawn(() => expensiveLoopThatNeverChecksAbort());
});

On this page