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.