Troubleshooting
This guide covers common issues you might encounter when using awaitly and how to resolve them.
Type Errors
Section titled “Type Errors”“Type ‘X’ is not assignable to type ‘Y’”
Section titled ““Type ‘X’ is not assignable to type ‘Y’””This usually happens when error types don’t match what the workflow expects.
// Error: Type '"UNKNOWN"' is not assignable to type '"NOT_FOUND" | "EMAIL_FAILED"'const workflow = createWorkflow('workflow', { fetchUser, sendEmail });
const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); await step.try( 'riskyOp', () => riskyOperation(), { error: 'UNKNOWN' } // Not in dependencies! ); return user;});// Option 1: Add the error type to a dependencyasync function riskyOp(): AsyncResult<void, 'UNKNOWN'> { ... }const workflow = createWorkflow('workflow', { fetchUser, sendEmail, riskyOp });
// Option 2: Use run() with explicit error typesconst result = await run<User, 'NOT_FOUND' | 'EMAIL_FAILED' | 'UNKNOWN'>( async ({ step }) => { ... });“Property ‘value’ does not exist on type ‘Result’”
Section titled ““Property ‘value’ does not exist on type ‘Result’””You’re trying to access .value without narrowing the type first.
const result = await workflow.run(async ({ step, deps }) => { ... });console.log(result.value); // Error!const result = await workflow.run(async ({ step, deps }) => { ... });
// Option 1: Check ok firstif (result.ok) { console.log(result.value); // Safe}
// Option 2: Use unwrap helpersimport { unwrap, unwrapOr } from 'awaitly';
const value = unwrap(result); // Throws if errconst value = unwrapOr(result, defaultValue); // Returns default if err“Argument of type ‘() => Promise<X>’ is not assignable”
Section titled ““Argument of type ‘() => Promise<X>’ is not assignable””Your operation doesn’t return a Result type.
const workflow = createWorkflow('workflow', { });
const result = await workflow.run(async ({ step, deps }) => { // Error: fetch returns Promise<Response>, not Result const response = await step('fetch', () => fetch('/api')); return response;});// Option 1: Use step.try for throwing codeconst result = await workflow.run(async ({ step, deps }) => { const response = await step.try( 'fetchApi', () => fetch('/api').then(r => r.json()), { error: 'FETCH_FAILED' } ); return response;});
// Option 2: Wrap in a Result-returning functionasync function fetchApi(): AsyncResult<Data, 'FETCH_FAILED'> { try { const res = await fetch('/api'); return ok(await res.json()); } catch { return err('FETCH_FAILED'); }}My error type is wider than I expected
Section titled “My error type is wider than I expected”err() takes a const type parameter, so error literals keep their literal
type on their own, no as const required:
const fetchUser = async (id: string) => { const user = await db.find(id); return user ? ok(user) : err('NOT_FOUND');};// error type is 'NOT_FOUND', not stringAnnotating the return type is still worth doing when you want one named error union that the compiler checks every branch against:
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { const user = await db.find(id); return user ? ok(user) : err('NOT_FOUND');};Annotating also keeps inferred error objects free of readonly modifiers,
which matters if an error carries an array you later assign to a mutable type.
Runtime Errors
Section titled “Runtime Errors”“Cannot read property ‘ok’ of undefined”
Section titled ““Cannot read property ‘ok’ of undefined””Your operation returned undefined instead of a Result.
async function fetchUser(id: string) { const user = await db.find(id); if (user) return ok(user); // Missing return statement for error case!}async function fetchUser(id: string): AsyncResult<User, 'NOT_FOUND'> { const user = await db.find(id); if (user) return ok(user); return err('NOT_FOUND'); // Always return a Result}Workflow hangs indefinitely
Section titled “Workflow hangs indefinitely”Usually caused by an unresolved promise or missing await.
- Forgot to
awaitan async step - Operation never resolves or rejects
- Infinite loop in operation
- Deadlock in concurrent operations
// Add timeouts to operationsconst result = await workflow.run(async ({ step, deps }) => { const data = await step.withTimeout( 'slowOp', () => deps.potentiallySlowOp(), { ms: 30000 } ); return data;});
// Use cancellationconst controller = new AbortController();setTimeout(() => controller.abort('timeout'), 60000);
const workflow = createWorkflow('workflow', deps, { signal: controller.signal });“Maximum call stack size exceeded”
Section titled ““Maximum call stack size exceeded””Usually caused by circular dependencies or infinite recursion.
// Problem: Circular dependency between workflowsconst workflowA = createWorkflow('workflow', { workflowB }); // workflowB depends on workflowA!
// Solution: Break the cycleconst workflowA = createWorkflow('workflow', { fetchData });const workflowB = createWorkflow('workflow', { processData });// Call them sequentially, not as dependenciesDebugging Workflows
Section titled “Debugging Workflows”Read the stack trace on cause
Section titled “Read the stack trace on cause”awaitly reports an UnexpectedError when a dependency throws. The original error and its stack frames remain on cause:
async function readConfig(path: string) { JSON.parse(await fs.readFile(path, 'utf8')); // throws return ok(path);}
const result = await run({ readConfig }, async (s) => s.readConfig('/etc/app'));
if (!result.ok) { console.log(result.error.type); // 'UnexpectedError' console.log(result.error.cause.stack);}SyntaxError: Expected property name or '}' in JSON at position 2 at JSON.parse (<anonymous>) at readConfig (/app/src/config.ts:4:8) at .../awaitly/dist/index.js:911:31The top frames identify the line in your code that threw. awaitly runs steps with await, so the JavaScript engine builds the same stack it builds for other async functions. Your debugger can step through those frames.
Log result.error.cause for failures you did not model. Inspect result.error.type to identify the workflow failure and cause.stack to find the throw site.
Enable workflow events
Section titled “Enable workflow events”Use the onEvent option to log what’s happening:
const workflow = createWorkflow('workflow', deps, { onEvent: (event) => { switch (event.type) { case 'step_start': console.log(`[START] ${event.name}`); break; case 'step_complete': console.log(`[DONE] ${event.name} (${event.durationMs}ms)`); break; case 'step_error': console.log(`[ERROR] ${event.name}:`, event.error); break; case 'step_retry': console.log(`[RETRY] ${event.name} attempt ${event.attempt}`); break; } },});Use visualization
Section titled “Use visualization”import { createVisualizer } from 'awaitly-visualizer';
const viz = createVisualizer({ workflowName: 'my-workflow' });const workflow = createWorkflow('workflow', deps, { onEvent: viz.handleEvent });
const result = await workflow.run(async ({ step, deps }) => { ... });
// See what happenedconsole.log(viz.renderAs('mermaid'));Visualizer not capturing events (empty or only “workflow_start”)
Section titled “Visualizer not capturing events (empty or only “workflow_start”)”If your diagram is empty or only shows the start node, events are not reaching the visualizer. Ensure:
- You pass
onEvent, The workflow only emits events to a callback you provide. PassonEvent: viz.handleEvent(orcollector.handleEvent) when creating the workflow or when calling the function that runs it. - Library workflows: If you call a function like
processOnePayment(..., deps)that creates the workflow internally, that function must accept optional workflow options (e.g.{ onEvent }) and forward them tocreateWorkflow. Otherwise your visualizer never receives events. See Visualization: Library workflows. - Steps run: The workflow must execute at least one
step(...)so thatstep_start/step_completeevents are emitted. If the workflow exits early or throws before any step, you may only seeworkflow_start.
Inspect step results
Section titled “Inspect step results”const workflow = createWorkflow('workflow', deps, { onEvent: (event) => { if (event.type === 'step_complete') { console.log('Step result:', event.result); } },});Performance Issues
Section titled “Performance Issues”Workflow is slow
Section titled “Workflow is slow”Sequential steps that could run in parallel:
const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); const posts = await step('fetchPosts', () => deps.fetchPosts('1')); // Waits for user const comments = await step('fetchComments', () => deps.fetchComments('1')); // Waits for posts return { user, posts, comments };});Run independent operations in parallel:
import { allAsync } from 'awaitly';
const result = await workflow.run(async ({ step, deps }) => { const [user, posts, comments] = await step('fetchUserData', () => allAsync([ deps.fetchUser('1'), deps.fetchPosts('1'), deps.fetchComments('1'), ]) ); return { user, posts, comments };});Memory usage is high
Section titled “Memory usage is high”- Use streaming for large data instead of loading everything
- Clear caches when done
- Use
processInBatchesfor large datasets
import { processInBatches } from 'awaitly';
// Instead of: await Promise.all(users.map(processUser))const result = await processInBatches( users, processUser, { batchSize: 100, concurrency: 5 });Too many API calls
Section titled “Too many API calls”Use singleflight to dedupe concurrent requests:
import { singleflight } from 'awaitly';
const fetchUserOnce = singleflight(fetchUser, { key: (id) => `user:${id}`,});
// 10 concurrent calls → 1 actual API callawait Promise.all(ids.map(id => fetchUserOnce(id)));Common Mistakes
Section titled “Common Mistakes”Using step() outside workflow
Section titled “Using step() outside workflow”// Error: step is only available inside workflow callbackconst user = await step('fetchUser', () => fetchUser('1')); // step is undefined!
// Correct: use inside workflowconst result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); return user;});Mixing throw and Result
Section titled “Mixing throw and Result”// Bad: inconsistent error handlingasync function fetchUser(id: string): AsyncResult<User, 'NOT_FOUND'> { const user = await db.find(id); if (!user) throw new Error('Not found'); // Don't throw! return ok(user);}
// Good: always return Resultasync function fetchUser(id: string): AsyncResult<User, 'NOT_FOUND'> { const user = await db.find(id); return user ? ok(user) : err('NOT_FOUND');}Not handling all error cases
Section titled “Not handling all error cases”// TypeScript doesn't enforce handling all errors by defaultconst result = await workflow.run(async ({ step, deps }) => { ... });
if (!result.ok) { // result.error could be 'NOT_FOUND' | 'EMAIL_FAILED' | UnexpectedError // Make sure to handle all cases!}
// Use exhaustive matching with TaggedErrorimport { TaggedError } from 'awaitly';
if (!result.ok) { const message = TaggedError.match(result.error, { NOT_FOUND: () => 'User not found', EMAIL_FAILED: () => 'Email delivery failed', // TypeScript will error if you miss a case });}Returning ok() from workflow executor (double-wrapping)
Section titled “Returning ok() from workflow executor (double-wrapping)”A common mistake: workflow executors should return raw values, not ok().
const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); return ok({ user }); // Wrong! Causes double-wrapping});// result.value is { ok: true, value: { user: ... } }// result.value.user is undefined!const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); return { user }; // Correct: return raw value});// result.value is { user: ... }Mental model:
- step() accepts
Result | AsyncResultand unwraps them - Workflow executors return raw values; awaitly wraps them
Getting Help
Section titled “Getting Help”If you’re still stuck:
- Check the API Reference for correct usage
- Look at example patterns for common scenarios
- Search GitHub Issues for similar problems
- Open a new issue with a minimal reproduction