Recipes
Find your situation in the headings below. Each recipe shows the smallest API that solves it, then links to the guide that goes deeper.
Every snippet here is copied from packages/awaitly/src/recipes.test.ts, which runs on
every build. If you change a recipe, change it there too.
Four tiers, one run()
Section titled “Four tiers, one run()”Most questions on this page reduce to picking a tier. Start at the top and stop as soon as one works.
| Your situation | Reach for |
|---|---|
| Call a dependency, unwrap it, exit on error | s.getOrders(id) |
| Every call to one dependency needs a retry or timeout | retry(getOrders, { attempts: 3 }) in the deps object |
| One specific call needs options | step('getOrders', () => getOrders(id), { retry }) |
| You need caching, resume, or events | createWorkflow(deps) |
import { run, retry, match } from 'awaitly';
const result = await run( { getUser, getOrders: retry(getOrders, { attempts: 3 }) }, // tier 2 async (s, { step }) => { const user = await s.getUser('1'); // tier 1 const orders = await step('getOrders', () => getOrders(user.id), { timeout: { ms: 5000 }, // tier 3 }); return { user, orders }; });
match(result, { ok: ({ user, orders }) => console.log({ user, orders }), NOT_FOUND: () => console.error('User not found'), VALIDATION_ERROR: () => console.error('Bad input'), FETCH_ERROR: () => console.error('Fetch error'), UnexpectedError: (error) => console.error(error.cause),});Tier 4 costs you a workflow name and a deps object. Skip it until you want the caching or the resume.
Start here
Section titled “Start here”Handle errors without exceptions
Section titled “Handle errors without exceptions”For minimal bundle use awaitly/result; for the full front-door API use awaitly. All imports are named.
// Minimal: from 'awaitly/result'. Or from 'awaitly' for the full front-door API.import { ok, err, type AsyncResult } from 'awaitly';
async function fetchUser(id: string): AsyncResult<User, 'NOT_FOUND'> { const user = await db.find(id); return user ? ok(user) : err('NOT_FOUND');}Compose multiple Result-returning functions
Section titled “Compose multiple Result-returning functions”Pass them to run(). Each call unwraps the ok value and exits on the first error.
import { run } from 'awaitly';
const result = await run({ fetchUser, chargeCard }, async (s) => { const user = await s.fetchUser('1'); const charge = await s.chargeCard(user.id, 100); return { user, charge };});// result.error is: 'NOT_FOUND' | 'CARD_DECLINED' | UnexpectedErrorReach for createWorkflow when you want caching, resume, or events on top:
import { createWorkflow } from 'awaitly';
const workflow = createWorkflow('workflow', { fetchUser, chargeCard });const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); const charge = await step('chargeCard', () => deps.chargeCard(user.id, 100)); return { user, charge };});// First arg = label (literal); optional key = instance (cache/identity)Adopt awaitly without rewriting anything
Section titled “Adopt awaitly without rewriting anything”A plain function that throws is a valid dep. Its value passes through, and its throws
arrive as UnexpectedError. Convert functions to Result one at a time.
const parseConfig = async (raw: string) => JSON.parse(raw) as { port: number };
const result = await run({ parseConfig }, async (s) => s.parseConfig(raw));// result.error is UnexpectedError, with the SyntaxError on .causeRead the result at the boundary
Section titled “Read the result at the boundary”Reach for match. One arm per error, plus ok, plus UnexpectedError:
import { match } from 'awaitly';
match(result, { ok: ({ user, orders }) => console.log({ user, orders }), NOT_FOUND: () => console.error('User not found'), VALIDATION_ERROR: () => console.error('Bad input'), FETCH_ERROR: () => console.error('Fetch error'), UnexpectedError: (error) => console.error(error.cause),});This is exhaustive. Add a dependency that fails a new way and every match over that
result stops compiling until you handle it. String errors key on themselves; tagged
errors and TaggedError classes key on their type.
match returns whatever the arms return, so it works as an expression:
type Response = { status: number; body: unknown };
const response = match(result, { ok: (value): Response => ({ status: 200, body: value }), NOT_FOUND: () => ({ status: 404, body: 'No such user' }), CARD_DECLINED: () => ({ status: 402, body: 'Payment declined' }), UnexpectedError: () => ({ status: 500, body: 'Internal error' }),});Annotate the ok arm when the arms return different shapes. TypeScript infers the
return type from the first arm it sees, so an unannotated ok returning
{ status, body: User } rejects the sibling arms that put a string in body.
Want a single catch-all instead of one arm per error? Use the two-arm form,
match(result, { ok, err }).
The lower-level form
Section titled “The lower-level form”if/switch still works, and you need it when the arms do more than return a value.
A Result is an object, so narrow on result.ok before you touch result.error:
if (result.ok) { return { status: 200, body: result.value };}if (isUnexpectedError(result.error)) { return { status: 500, body: 'Internal error' };}switch (result.error) { case 'NOT_FOUND': return { status: 404, body: 'No such user' }; case 'CARD_DECLINED': return { status: 402, body: 'Payment declined' };}Two traps live here, and match avoids both:
switch (result)instead ofswitch (result.error)gets youType 'string' is not comparable to type 'Result<...>'(TS2678).isUnexpectedError(result)instead ofisUnexpectedError(result.error)compiles and returnsfalseevery time. Pass the error, never theResult.
Make it survive a bad day
Section titled “Make it survive a bad day”Retry every call to one dependency
Section titled “Retry every call to one dependency”Wrap it where you declare it. Call sites stay untouched, and the analyzer reads the policy straight out of the deps literal.
import { run, retry } from 'awaitly';
const result = await run( { fetchApi: retry(fetchUnreliableAPI, { attempts: 3, backoff: 'exponential', delay: 100 }) }, async (s) => s.fetchApi());See Policies for retry, timeout, and fallback, which compose:
retry(timeout(charge, 5000), { attempts: 3 }).
delay and initialDelay mean the same thing here, as do retryIf and shouldRetry,
so whichever you remember from step options also works on the policy. The defaults still
differ: policies start at backoff: 'fixed' with no delay, step retries start at
backoff: 'exponential' with a 100ms initialDelay and jitter on.
By default, retry skips UnexpectedError and untagged throws (bugs). Typed errors still
retry. Pass retryIf: () => true to opt back into retrying throws.
In tests, pass createTestClock() as clock on run, createWorkflow / workflow.run,
retry, timeout(fn, after, { clock }), or createCircuitBreaker so delays and windows
do not wait on real time. Policy wrappers do not pick up run({ clock }) automatically.
On step.retry, set jitter: false so delays are exact. See
Retries & Timeouts.
Add timeouts to operations
Section titled “Add timeouts to operations”Same three tiers. Per dependency:
import { run, timeout } from 'awaitly';
const result = await run({ slowOp: timeout(slowOperation, 5000) }, async (s) => s.slowOp());// error union gains TimeoutErrorPer call, via step options (note the { ms } object):
const data = await step('slowOp', () => slowOperation(), { timeout: { ms: 5000 } });Inside a workflow, step.withTimeout('slowOp', () => slowOperation(), { ms: 5000 }) does
the same thing with a dedicated helper.
Fall back to a default when a dependency fails
Section titled “Fall back to a default when a dependency fails”import { run, fallback } from 'awaitly';
const result = await run( { sendEmail: fallback(sendEmail, () => ({ queued: true })) }, async (s) => s.sendEmail(user.id));// sendEmail's errors are consumed, so they never reach result.errorRetry one specific call
Section titled “Retry one specific call”Take step from the second callback argument. You keep the bound steps for
everything else.
const result = await run({ fetchUser, fetchApi }, async (s, { step }) => { const user = await s.fetchUser('1'); const data = await step('fetchApi', () => fetchUnreliableAPI(user.id), { retry: { attempts: 3, backoff: 'exponential', initialDelay: 100 }, }); return data;});Inside a workflow the same options live on step, plus the step.retry(id, fn, opts)
helper. See Retries & Timeouts.
Timeout behavior variants
Section titled “Timeout behavior variants”// Default: return error on timeout{ ms: 5000, onTimeout: 'error' }
// Return undefined instead of error (optional operation){ ms: 1000, onTimeout: 'option' }
// Return error but let operation finish in background{ ms: 2000, onTimeout: 'disconnect' }
// Custom error handler{ ms: 5000, onTimeout: ({ name, ms }) => ({ _tag: 'Timeout', name, ms }) }Do several things at once
Section titled “Do several things at once”Run multiple operations in parallel
Section titled “Run multiple operations in parallel”import { allAsync, anyAsync, allSettledAsync, createWorkflow } from 'awaitly';
// Inside a workflow: step.all (named results, step tracking)await workflow.run(async ({ step, deps }) => { const { user, posts } = await step.all('fetchAll', { user: () => deps.fetchUser('1'), posts: () => deps.fetchPosts('1'), }); const users = await step.map('fetchUsers', ['1', '2', '3'], (id) => deps.fetchUser(id)); return { user, posts, users };});
// Standalone: allAsync — all must succeed (fail-fast)const [user, posts] = await allAsync([fetchUser('1'), fetchPosts('1')]);
// First success wins (failover pattern)const data = await anyAsync([fetchFromPrimary(), fetchFromBackup()]);
// Collect ALL errors (if any fail)const result = await allSettledAsync([op1(), op2(), op3()]);if (!result.ok) console.log('Errors:', result.error.map(e => e.error));Combine two Results into a tuple
Section titled “Combine two Results into a tuple”import { zip, zipAsync, andThen } from 'awaitly';
// Sync: combine two Resultsconst combined = zip(userResult, postsResult);// combined: Result<[User, Post[]], UserError | PostsError>
// Async: run two fetches in parallelconst data = await zipAsync(fetchUser('1'), fetchPosts('1'));if (data.ok) { const [user, posts] = data.value;}
// Chain with andThenconst dashboard = andThen( zip(userResult, postsResult), ([user, posts]) => createDashboard(user, posts));Process large datasets in batches
Section titled “Process large datasets in batches”import { processInBatches, batchPresets } from 'awaitly';
const result = await processInBatches( users, async (user) => migrateUser(user), { batchSize: 50, concurrency: 5 }, { onProgress: (p) => console.log(`${p.percent}%`) });Dedupe concurrent requests
Section titled “Dedupe concurrent requests”import { singleflight } from 'awaitly';
const fetchUserOnce = singleflight(fetchUser, { key: (id) => `user:${id}`,});
// 3 concurrent calls → 1 network requestconst [a, b, c] = await Promise.all([ fetchUserOnce('1'), fetchUserOnce('1'), // Shares request fetchUserOnce('1'), // Shares request]);Reach for a workflow
Section titled “Reach for a workflow”Compose steps inside a workflow
Section titled “Compose steps inside a workflow”Inside a workflow callback use ({ step, deps }) => { ... } when the workflow has deps:
// Unwrap an AsyncResult (cache by passing { key })const user = await step('fetchUser', () => fetchUser('1'), { key: 'user:1' });
// Chain — just call step again with the success valueconst enriched = await step('enrich', () => enrichUser(user));
// Pattern match — branch on the unwrapped valueconst msg = user.name ? `Hello ${user.name}` : 'Failed';Skip work you already did
Section titled “Skip work you already did”Caching is opt-in twice: pass a cache to createWorkflow, and give each cacheable
step a key. A key with no cache configured is inert, so the step runs every time.
const cache = new Map();const checkout = createWorkflow('checkout', { charge }, { cache });
await checkout.run(async ({ step, deps }) => step('charge', () => deps.charge(100), { key: 'order-1' }));// A second run with the same key reads the cache and does not charge again.Options passed to workflow.run() rather than createWorkflow() are ignored in
silence. See Caching.
Persist and resume workflow state
Section titled “Persist and resume workflow state”import { createWorkflow } from 'awaitly';import { postgres } from 'awaitly-postgres';
const store = postgres(process.env.DATABASE_URL!);const workflow = createWorkflow('workflow', deps);
const { result, resumeState } = await workflow.runWithState(fn);await store.save(workflowId, resumeState);
// Resume later (use loadResumeState for type-safe restore)const loaded = await store.loadResumeState(workflowId);if (loaded) await workflow.run(fn, { resumeState: loaded });Three things to know before you rely on this:
- Only keyed steps are recorded. A step without
keyruns again on every resume. resumeState.stepsis aMap.JSON.stringifyturns it into{}and reports no error, so a hand-rolled store saves an empty state. UseserializeResumeStateanddeserializeResumeStatewhen you persist as JSON.- Failed steps are recorded too, and replay returns the recorded failure. Resuming a crashed run reproduces the same error without calling the dependency again. Drop the failures first when you want a retry:
const retryable = { steps: new Map([...loaded.steps].filter(([, entry]) => entry.result.ok)),};await workflow.run(fn, { resumeState: retryable });Cancel workflow from outside
Section titled “Cancel workflow from outside”import { createWorkflow, isWorkflowCancelled } from 'awaitly';
const controller = new AbortController();const workflow = createWorkflow('workflow', deps, { signal: controller.signal });
const resultPromise = workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => fetchUser('1'), { key: 'user' }); await step('sendEmail', () => sendEmail(user.email), { key: 'email' }); return user;});
// Cancel from outside (e.g., timeout, user action)setTimeout(() => controller.abort('timeout'), 5000);
const result = await resultPromise;if (!result.ok && isWorkflowCancelled(result.cause)) { console.log('Cancelled:', result.cause.reason);}Undo completed steps when one fails
Section titled “Undo completed steps when one fails”import { createSagaWorkflow } from 'awaitly/durable';
const saga = createSagaWorkflow('checkout', { charge, refund, reserve, release });const result = await saga.run(async ({ step, deps }) => { const payment = await step('charge', () => deps.charge({ amount: 100 }), { compensate: (p) => deps.refund({ id: p.id }), }); // If a later step fails, charge is automatically refunded (LIFO order) const reservation = await step('reserve', () => deps.reserve({ items }), { compensate: (r) => deps.release({ id: r.id }), }); return { payment, reservation };});A rollback that fails is reported, not swallowed. If refund returns err(...) or throws,
the saga returns SAGA_COMPENSATION_ERROR carrying both the original error and every
compensation that failed:
if (!result.ok && isSagaCompensationError(result.error)) { console.error('rollback incomplete', result.error.compensationErrors); console.error('what started it', result.error.originalError);}Wait for human approval
Section titled “Wait for human approval”import { createApprovalStep, isPendingApproval } from 'awaitly/durable';import { createResumeStateCollector } from 'awaitly';
const approvalStep = createApprovalStep({ key: 'manager-approval', checkApproval: async () => { const record = await db.approvals.find('workflow-123'); if (!record) return { status: 'pending' }; // The 'rejected' arm requires a `reason`. return record.approved ? { status: 'approved', value: record } : { status: 'rejected', reason: record.rejectionReason ?? 'Not approved' }; },});
const collector = createResumeStateCollector();const workflow = createWorkflow('workflow', deps, { onEvent: collector.handleEvent });
// Workflow pauses at approval stepconst result = await workflow.run(async ({ step, deps }) => { const data = await step('fetchData', () => deps.fetchData()); const approval = await step('approval', approvalStep); return finalize(data);});
if (!result.ok && isPendingApproval(result.error)) { const state = collector.getResumeState(); await store.save(workflowId, state);}Prevent cascading failures
Section titled “Prevent cascading failures”import { createCircuitBreaker, isCircuitOpenError } from 'awaitly';
const breaker = createCircuitBreaker('payment-api', { failureThreshold: 5, resetTimeout: 30000,});
const result = await breaker.executeResult(() => paymentAPI.charge());if (!result.ok && isCircuitOpenError(result.error)) { // Circuit is open - fail fast without calling the API}Test workflows deterministically
Section titled “Test workflows deterministically”import { createWorkflowHarness, okOutcome, errOutcome } from 'awaitly/testing';
const harness = createWorkflowHarness(deps);harness.script([ okOutcome({ id: '1', name: 'Alice' }), errOutcome('PAYMENT_DECLINED'),]);
const result = await harness.run(async ({ step, deps }) => { const user = await step('fetchUser', () => fetchUser('1')); const charge = await step('chargeCard', () => chargeCard(100)); return { user, charge };});
expect(result.ok).toBe(false);harness.assertSteps(['fetch-user', 'charge-card']);Import Cheatsheet
Section titled “Import Cheatsheet”Use the task-shaped entry point for the capability you need. Everything is a named import: there is no namespace object.
| Need | Import from |
|---|---|
| Result types only (minimal bundle) | awaitly/result |
Result types + composition (ok, err, isOk, isErr, map, mapError, andThen, tap, from, fromPromise, all, allAsync, partition, match, TaggedError) |
awaitly |
run() for step composition |
awaitly |
Parallel ops (allAsync, allSettledAsync, zip, zipAsync) |
awaitly |
Retry policy for async/Result code (retry) |
awaitly |
Circuit breaker (createCircuitBreaker, isCircuitOpenError) |
awaitly |
| Rate limiting | awaitly |
Singleflight (singleflight, createSingleflightGroup) |
awaitly |
Duration helpers (Duration, seconds, minutes) |
awaitly |
| Tagged errors, pattern matching | awaitly |
Pre-built errors (TimeoutError, RetryExhaustedError, RateLimitError, etc.) |
awaitly |
Conditionals (when, unless) |
awaitly |
Workflow engine (createWorkflow, isStepComplete, createResumeStateCollector, isWorkflowCancelled, step types, ResumeState) |
awaitly |
Workflow instance (.run(name?, fn, config?)) |
Returned by createWorkflow |
Durable execution (durable.run) |
awaitly/durable |
Saga pattern (createSagaWorkflow) |
awaitly/durable |
HITL (pendingApproval, createApprovalStep, gatedStep, injectApproval, isPendingApproval) |
awaitly/durable |
Snapshot store types and validation (SnapshotStore, WorkflowSnapshot, validateSnapshot) |
awaitly/durable |
Streaming (createMemoryStreamStore, toAsyncIterable, transformers) |
awaitly/durable |
Webhooks (createWebhookHandler) |
awaitly/durable |
Runtime engine (createEngine) |
awaitly/durable |
Batch processing (processInBatches) |
awaitly |
| Testing utilities | awaitly/testing |
| Visualization | awaitly-visualizer (createVisualizer, Mermaid/ASCII/JSON; optional React UI) |
Entry points
Section titled “Entry points”| Entry Point | Use Case |
|---|---|
awaitly/result |
Result types only (smallest bundle; sizes in docs are gzipped when given) |
awaitly |
The front door: Result types, run(), createWorkflow(), per-dep policies, circuit breakers, rate limiting, caching, singleflight, pattern matching, durations, pre-built errors, and batch processing |
awaitly/durable |
Production machinery: durable execution, snapshot persistence, saga/compensation, human-in-the-loop, streaming stores, webhooks, and the low-level engine |
awaitly/testing |
Test utilities (createWorkflowHarness, scripted outcomes, assertions) |
Decision Matrix
Section titled “Decision Matrix”| Scenario | Pattern | Key APIs |
|---|---|---|
| Linear multi-step operations | Workflow | createWorkflow, step() |
| Steps that may need rollback | Saga | createSagaWorkflow, compensate |
| Independent parallel calls | Parallel | allAsync(), allSettledAsync() |
| First success wins (failover) | Race | anyAsync() |
| Human approval gates | HITL | createApprovalStep(), injectApproval() |
| Cancel from outside | Cancellation | signal, isWorkflowCancelled() |
| Dedupe concurrent requests | Singleflight | singleflight() |
| High-volume processing | Batch | processInBatches() |
| Flaky external APIs | Circuit Breaker | createCircuitBreaker() |
| Rate-limited APIs | Rate Limiter | createRateLimiter() |
| Rich typed errors | Tagged Errors | TaggedError(), TimeoutError, etc. |
Common Patterns
Section titled “Common Patterns”Typed error domains
Section titled “Typed error domains”// Define error types per domaintype UserError = 'NOT_FOUND' | 'SUSPENDED';type PaymentError = 'DECLINED' | 'EXPIRED' | 'LIMIT_EXCEEDED';
// Workflows automatically union all possible errorsconst workflow = createWorkflow('workflow', { fetchUser, chargeCard });// result.error is: UserError | PaymentError | UnexpectedErrorExtracting error types from functions
Section titled “Extracting error types from functions”import type { ErrorOf, Errors } from 'awaitly';
type FetchUserError = ErrorOf<typeof fetchUser>; // 'NOT_FOUND' | 'SUSPENDED'type AllErrors = Errors<[typeof fetchUser, typeof chargeCard]>; // Union of allUnwrapping results
Section titled “Unwrapping results”import { unwrap, unwrapOr, unwrapOrElse, UnwrapError } from 'awaitly';
// Throws UnwrapError if errconst user = unwrap(result);
// Returns default if errconst user = unwrapOr(result, defaultUser);
// Compute default from errorconst user = unwrapOrElse(result, (error) => createGuestUser(error));Transforming results
Section titled “Transforming results”import { map, mapError, andThen, match } from 'awaitly';
// Transform value (if ok)const name = map(userResult, user => user.name);
// Transform error (if err)const apiError = mapError(result, error => ({ code: 'API_ERROR', cause: error }));
// Chain operations (flatMap)const posts = andThen(userResult, user => fetchPosts(user.id));
// Pattern matchconst message = match(result, { ok: (user) => `Hello, ${user.name}!`, err: (error) => `Failed: ${error}`,});Using tagged errors
Section titled “Using tagged errors”import { TaggedError, TimeoutError, RetryExhaustedError, ValidationError, isAwaitlyError } from 'awaitly';
// Create typed errorsconst timeout = new TimeoutError({ operation: 'fetchUser', ms: 5000 });const validation = new ValidationError({ field: 'email', reason: 'Invalid format' });
// Pattern match on errorsconst message = TaggedError.match(error, { TimeoutError: (e) => `Timed out after ${e.ms}ms`, RetryExhaustedError: (e) => `Failed after ${e.attempts} attempts`, ValidationError: (e) => `Invalid ${e.field}: ${e.reason}`,});
// Type guardif (isAwaitlyError(error)) { console.log('Awaitly error:', error.type);}See Also
Section titled “See Also”| Topic | Guide |
|---|---|
| Common issues | Troubleshooting |
| Framework setup | Framework Integration |
| Production best practices | Production Deployment |
| Complete API | API Reference |