Yieldless
Guides

Do and Don't

Practical conventions that keep Yieldless code readable instead of ceremonial.

This page is the short list of habits that keep tuple-based code clean under real production pressure.

Error handling

✓ Keep the tuple close to the boundary.

Wrap I/O once, then pass domain values through the rest of the function.

✗ Wrap every line in safeTry().

If a function is already synchronous and pure, let it stay ordinary code.

const [readError, configText] = await readFileSafe("config.json");
if (readError) return [readError, null];

const [parseError, config] = safeTrySync(() => JSON.parse(configText));
if (parseError) return [parseError, null];

✓ Use result helpers when they remove branch noise.

return mapOk(
  fromNullable(user, () => new NotFoundError("User not found")),
  toUserView,
);

✗ Build a pipeline because it feels more abstract.

return andThen(andThen(andThen(result, a), b), c);

If the branch reads better, use the branch.

Cancellation

✓ Pass the signal all the way into the I/O API.

Cancellation only matters when the transport or subprocess actually sees the signal.

✗ Assume a task group can kill arbitrary CPU work.

AbortSignal is cooperative. If your code ignores it, the work keeps running.

Retries

✓ Retry transport failures and transient infrastructure noise.

Network timeouts, brief lock contentions, and flaky subprocess startup are reasonable candidates.

✗ Retry validation failures or deterministic domain errors.

If the request shape is bad on attempt one, it will still be bad on attempt three.

Timeouts and timers

✓ Use one deadline signal for the whole boundary.

const [error, response] = await fetchJsonSafe(url, {
  timeoutMs: 5_000,
  signal,
});

✗ Scatter unrelated setTimeout calls through business logic.

Timers without abort handling tend to outlive the work they were created for.

Parallel work

✓ Put a ceiling on list-shaped work.

await mapAsyncLimit(readRows(file), processRow, {
  concurrency: 4,
  signal,
});

✗ Start one promise for every item in a large collection.

await Promise.all(rows.map(processRow));

Small lists are fine. Unknown or user-sized lists need pressure control.

Dependency injection

✓ Bind stable dependencies at the edge.

Loggers, repositories, mailers, and feature flags are good candidates for inject().

✗ Build a hidden service locator around it.

If the dependencies are not obvious from the function signature, the code gets harder to review.

Configuration

✓ Parse environment once.

const [error, config] = parseEnvSafe(configSchema);

✗ Read process.env from deep application code.

It makes tests harder and hides what the function actually needs.

Context

✓ Use async context for request-scoped metadata.

Trace spans, request IDs, user sessions, or a transaction handle fit well.

✗ Use async context as your application container.

Configuration and stable dependencies should still be explicit.

Boundaries

A good rule of thumb: tuples are for work you expect to fail sometimes, thrown exceptions are for code that truly cannot continue. If a boundary requires exceptions, convert at that one spot with unwrap() and move on.

IPC and UI state

✓ Keep tuples at transport and service boundaries.

const result = await window.gitBridge.withSignal.status(signal, repoPath);

✓ Convert tuples into view state before rendering.

return match(result, {
  ok: (data) => ({ kind: "ready" as const, data }),
  err: (error) => ({ kind: "error" as const, message: error.message }),
});

✗ Pass raw tuple results through every component prop.

React, Vue, and Svelte components are usually clearer with domain-specific screen state.

On this page