Skip to content

Your First Workflow

createWorkflow() is run() with a name attached. Same deps-first idea, same unwrapping, same inferred error union — but because the workflow has a name and a fixed set of deps, awaitly can also test it, retry it, persist it, and draw it.

Nothing new here — operations return AsyncResult<T, E>:

import { ok, err, type AsyncResult } from 'awaitly';
type User = { id: string; name: string };
type Post = { id: number; title: string };
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
id === '1' ? ok({ id: '1', name: 'Alice' }) : err('NOT_FOUND');
const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
ok([{ id: 1, title: 'Hello World' }]);
import { createWorkflow } from 'awaitly';
const loadUserData = createWorkflow('loadUserData', { fetchUser, fetchPosts });

The first argument is the workflow name. It is not decoration — it’s the identifier used in diagrams, traces, persisted state, and analyzer output.

The callback receives { steps } — the same bound object run() gave you:

const result = await loadUserData.run(async ({ steps }) => {
const user = await steps.fetchUser('1');
const posts = await steps.fetchPosts(user.id);
return { user, posts };
});

If fetchUser returns err('NOT_FOUND'), the callback stops there and result.error is 'NOT_FOUND'. Identical to run().

if (result.ok) {
console.log(result.value.user.name, result.value.posts.length);
} else {
switch (result.error) {
case 'NOT_FOUND': console.log('User not found'); break;
case 'FETCH_ERROR': console.log('Failed to fetch posts'); break;
default: console.log('Threw:', result.error.cause);
}
}

result.error is 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError, inferred from the deps. See What TypeScript gives you back.

Because the workflow is named, this now works:

Terminal window
npx awaitly-analyze ./src/load-user-data.ts
flowchart TB
  start([loadUserData]) --> fetchUser
  fetchUser -->|ok| fetchPosts
  fetchUser -->|NOT_FOUND| fail([error])
  fetchPosts -->|ok| done([ok])
  fetchPosts -->|FETCH_ERROR| fail

The diagram is generated from your source — no annotations, no separate spec file. Add a step and the diagram changes; delete one and it disappears. Add --assert-diagrammable in CI and a workflow that drifts out of shape fails the build.

That is the reason to name workflows, and the reason to prefer steps.fetchUser(id) over hand-written control flow. See Static Analysis.

You want Use
Compose a few operations, once run(deps, fn)
A named unit that appears in diagrams createWorkflow(name, deps)
Swap deps in tests createWorkflow(name, deps)
Retries, timeouts, caching createWorkflow(name, deps)
Resume after a crash createWorkflow(name, deps)

Both infer the error union from deps. The difference is what you can do afterwards.

import { ok, err, type AsyncResult, createWorkflow } from 'awaitly';
type User = { id: string; name: string };
type Post = { id: number; title: string };
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
id === '1' ? ok({ id: '1', name: 'Alice' }) : err('NOT_FOUND');
const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
ok([{ id: 1, title: 'Hello World' }]);
const loadUserData = createWorkflow('loadUserData', { fetchUser, fetchPosts });
const result = await loadUserData.run(async ({ steps }) => {
const user = await steps.fetchUser('1');
const posts = await steps.fetchPosts(user.id);
return { user, posts };
});
if (result.ok) {
console.log(`${result.value.user.name} has ${result.value.posts.length} posts`);
}

What TypeScript gives you back →