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:
yieldless/errorto keep failures in tuple formyieldless/resultwhen tuple branches need light compositionyieldless/task,yieldless/all,yieldless/iterable,yieldless/queue,yieldless/pubsub, andyieldless/limiterto fan work out, hand it off, and control pressureyieldless/retry,yieldless/schedule,yieldless/signal,yieldless/timer, andyieldless/breakerfor resilient async boundariesyieldless/cache,yieldless/batcher, andyieldless/singleflightfor repeated, keyed, and duplicate work- Boundary adapters like
yieldless/fetch,yieldless/schema,yieldless/router,yieldless/ipc,yieldless/node, andyieldless/env
Install
pnpm add yieldlessTypeScript 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.
Step 2: group related work under one cancellation signal
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/envvalidates startup configurationyieldless/fetchwraps HTTP status, JSON parsing, and deadlinesyieldless/schemakeeps validators in tuple formyieldless/routerturns tuple handlers into HTTP responsesyieldless/ipcpreserves tuple results across Electron IPCyieldless/nodewraps filesystem and child-process workyieldless/eventwaits for one event with cleanup and abort supportyieldless/schedulebuilds reusable retry and repeat policiesyieldless/limiterprotects shared capacity with semaphores and rate limitsyieldless/queueandyieldless/pubsubcoordinate in-process workers and subscribersyieldless/cachekeeps successful read-through loads fresh for a TTLyieldless/batchercollapses nearby keyed reads into ordered batchesyieldless/breakerbacks off dependencies that are already failingyieldless/singleflightdeduplicates duplicate in-flight workyieldless/testmakes 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()plusfetchJsonSafe()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());
});