# Stop Throwing Errors. Awaitly Makes Async Error Handling Actually Work

By Jag Reehal, 2026-01-22. Canonical: https://arrangeactassert.com/posts/stop-throwing-errors/

<!-- Excerpt Start -->

![Stop Throwing Errors](/_astro/stop-throwing-errors.CSMaM_f8_Z1OR3Fe.webp)

We've all written this code:

```ts
const lambdaHandler = async () => {
  try {
    const db = await connectToDb();
    const result = await errorHandler({ taskId, error }, { db });
    return { statusCode: 200, body: { message: 'Success', task: result } };
  } catch (error) {
    return { statusCode: 500, body: { message: 'Error' } };
  }
}
```

That `catch (error)` swallows everything. Was it a "task not found"? A database connection failure? A permissions issue? Who knows.

Throwing exceptions for expected failures is like using GOTO. You lose the thread.

[Awaitly](https://github.com/jagreehal/awaitly) fixes this by treating errors as data, not explosions. This guide teaches the patterns one concept at a time.

<!-- Excerpt End -->

## Part 1: Foundation (No Library)

Before we touch Awaitly, let's fix some fundamental patterns using pure TypeScript. These are good practices regardless of what error handling library you use.

### Step 0: The Starting Point

Here's a typical function that creates its own dependency:

```ts
async function getTask(taskId: string): Promise<Task> {
  // This runs every time!
  const db = await connectToDb();

  const task = await db.findTask(taskId);
  if (!task) throw new Error('Task not found');
  return task;
}

async function handler(event: { taskId: string }) {
  try {
    const task = await getTask(event.taskId);
    return { statusCode: 200, body: { task } };
  } catch {
    return { statusCode: 500, body: { message: 'Error' } };
  }
}
```

The problem: the db connection is created every time `getTask` is called. We can't test `getTask` without a real database, and we can't reuse the connection across multiple functions.

Call `handler` twice and you connect to the database twice. Wasteful.

### Step 1: Move Dependencies to the Boundary

Create dependencies at the "boundary" (your handler), then pass them down.

```ts
type Db = { findTask: (id: string) => Promise<Task | null> };

// db is passed IN as a parameter
async function getTask(taskId: string, db: Db): Promise<Task> {
  const task = await db.findTask(taskId);
  if (!task) throw new Error('Task not found');
  return task;
}

// Create db ONCE at the boundary
const db = await connectToDb();

async function handler(event: { taskId: string }, deps: { db: Db }) {
  try {
    const task = await getTask(event.taskId, deps.db);
    return { statusCode: 200, body: { task } };
  } catch {
    return { statusCode: 500, body: { message: 'Error' } };
  }
}

// Call handler twice with the SAME db instance
await handler({ taskId: 't-1' }, { db });
await handler({ taskId: 't-1' }, { db });
// db connected only ONCE, reused across both calls
```

Now `db` is created once per handler invocation (not per function call), `getTask` can be tested by passing a mock db, and the connection is reusable across multiple function calls.

Bonus: `getTask` is now testable with a mock:

```ts
const mockDb: Db = {
  findTask: async () => ({ _id: 'mock', status: 'Pending' }),
};
const mockResult = await getTask('any-id', mockDb);
```

### Step 2: The fn(args, deps) Pattern

Let's standardise on a consistent function signature:

```ts
// fn({ ...args }, { ...deps })
//    ^^^^^^^^^^   ^^^^^^^^^^
//    what to do   how to do it

async function getTask(
  args: { taskId: string },
  deps: { db: Db }
): Promise<Task> {
  const task = await deps.db.findTask(args.taskId);
  if (!task) throw new Error('Task not found');
  return task;
}

// Call with named parameters
const task = await getTask({ taskId: 't-1' }, { db });
```

This gives you a consistent shape across all business functions, clear separation of "what" vs "how", self-documenting named parameters, and it's easy to add new args or deps without changing call sites.

Compare the readability:

```ts
// Positional args: what's what?
getTask('t-1', db, cache, logger);

// Named args: crystal clear
getTask({ taskId: 't-1' }, { db, cache, logger });
//       ^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^
//       business args     infrastructure deps
```

### Step 3: Return null for Expected Failures

Throwing for "not found" is using exceptions for control flow. Return `null` instead:

```ts
async function getTask(
  args: { taskId: string },
  deps: { db: Db }
): Promise<Task | null> {
  const task = await deps.db.findTask(args.taskId);
  if (!task) return null;  // Expected failure, not an exception
  return task;
}

// Found case
const found = await getTask({ taskId: 't-1' }, { db });
expect(found).not.toBeNull();

// Not found case: no try/catch needed!
const notFound = await getTask({ taskId: 't-missing' }, { db });
expect(notFound).toBeNull();
```

The problem with this approach:

```ts
const result = await getTask({ taskId: 't-missing' }, { db });
// result is null... but WHY?
// Was it not found? Permission error? DB down?
```

`null` tells us it failed, but not why. We'll solve this in Part 2.

## Part 2: Typed Results

Now we introduce Awaitly's `Result` type, one piece at a time.

### Step 4: Introduce ok() for Explicit Success

Wrap successful returns in `ok(value)`:

```ts
import { ok, type AsyncResult } from 'awaitly';

async function getTask(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, never> {
  const task = await deps.db.findTask(args.taskId);
  return ok(task);  // Wrapped in ok()
}

const result = await getTask({ taskId: 't-1' }, { db });
// result.ok === true
// result.value === Task
```

Success is now explicit, not implicit. It prepares for adding error cases (next step), and `result.ok` tells you immediately if it succeeded.

The `AsyncResult<Task, never>` type means: "This returns a Task on success, and never returns an error." We'll change that `never` to an actual error type next.

### Step 5: Introduce err() for Named Failures

Return `err('ERROR_CODE')` instead of `null`:

```ts
import { ok, err } from 'awaitly';

async function getTask(args: { taskId: string }, deps: { db: Db }) {
  const task = await deps.db.findTask(args.taskId);
  if (!task) return err('TASK_NOT_FOUND');  // Named error!
  return ok(task);
}

// Success case
const success = await getTask({ taskId: 't-1' }, { db });
// success.ok === true, success.value === Task

// Error case: we know WHAT went wrong
const failure = await getTask({ taskId: 't-missing' }, { db });
// failure.ok === false, failure.error === 'TASK_NOT_FOUND'
```

Now errors have a name, not just `null`.

### Step 6: AsyncResult Type Annotation

Add explicit return types so TypeScript enforces correctness:

```ts
import { ok, err, type AsyncResult } from 'awaitly';

async function getTask(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, 'TASK_NOT_FOUND'> {
//            ^^^^  ^^^^^^^^^^^^^^^^^
//            Success type    Error type
  const task = await deps.db.findTask(args.taskId);
  if (!task) return err('TASK_NOT_FOUND');
  return ok(task);
}

const result = await getTask({ taskId: 't-1' }, { db });

// TypeScript now knows:
// - If result.ok === true, result.value is Task
// - If result.ok === false, result.error is 'TASK_NOT_FOUND'
```

`AsyncResult<T, E>` is just `Promise<Ok<T> | Err<E>>`. The type annotation tells TypeScript exactly what success and failure look like.

### Step 7: Rich Error Objects

Use error objects with `type` plus context instead of plain strings:

```ts
// Rich error type with context
type TaskNotFound = { type: 'TASK_NOT_FOUND'; taskId: string };

async function getTask(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, TaskNotFound> {
  const task = await deps.db.findTask(args.taskId);
  if (!task) {
    return err({
      type: 'TASK_NOT_FOUND',
      taskId: args.taskId,  // Include context!
    });
  }
  return ok(task);
}

const result = await getTask({ taskId: 't-missing' }, { db });

if (!result.ok) {
  // Now we have rich error info for logging/debugging
  console.error(`${result.error.type}: Task ${result.error.taskId} not found`);
  // Output: TASK_NOT_FOUND: Task t-missing not found
}
```

Errors now carry useful debugging information. You can include taskId, timestamps, details, stack traces. Makes logging and error handling much richer.

## Part 3: Composing Results

Multiple functions that return Results: how do we compose them cleanly?

### Step 8: The Boilerplate Problem

Call multiple `AsyncResult` functions and handle each manually:

```ts
type TaskNotFound = { type: 'TASK_NOT_FOUND'; taskId: string };
type UpdateFailed = { type: 'UPDATE_FAILED'; message: string };

async function getTask(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, TaskNotFound> {
  const task = await deps.db.findTask(args.taskId);
  if (!task) return err({ type: 'TASK_NOT_FOUND', taskId: args.taskId });
  return ok(task);
}

async function markErrored(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, UpdateFailed> {
  try {
    const updated = await deps.db.updateTask(args.taskId, { status: 'Errored' });
    return ok(updated);
  } catch (e) {
    return err({ type: 'UPDATE_FAILED', message: e instanceof Error ? e.message : 'Unknown' });
  }
}

// Manual composition: lots of boilerplate!
async function handleError(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, TaskNotFound | UpdateFailed> {
  // Step 1: Get task
  const taskResult = await getTask({ taskId: args.taskId }, deps);
  if (!taskResult.ok) return taskResult;  // Boilerplate

  // Step 2: Mark errored
  const updateResult = await markErrored({ taskId: taskResult.value._id }, deps);
  if (!updateResult.ok) return updateResult;  // More boilerplate

  // Imagine 5 more steps... that's 5 more if-checks!
  return updateResult;
}
```

Every call needs `if (!result.ok) return result`. The happy path gets buried in error-checking ceremony.

### Step 9: run() for Linear Composition

Use `run()` to compose multiple `AsyncResult` functions. `step()` unwraps ok values; err values exit early.

```ts
import { run, ok, err } from 'awaitly';

type TaskNotFound = { type: 'TASK_NOT_FOUND'; taskId: string };
type Unexpected = { type: 'UNEXPECTED'; cause: unknown };
type HandlerError = TaskNotFound | Unexpected;

async function handleError(
  args: { taskId: string },
  deps: { db: Db }
): AsyncResult<Task, HandlerError> {
  return run<Task, HandlerError>(
    async (step) => {
      // step(id, thunk): id for observability; thunk unwraps ok values. Err values exit the run() immediately.
      const task = await step('getTask', () => getTask({ taskId: args.taskId }, deps));
      // task is Task, not Result<Task, ...>: already unwrapped!

      const updated = await step('markErrored', () => markErrored({ taskId: task._id }, deps));
      // If getTask returned err, we never reach this line

      return updated;
    },
    {
      // catchUnexpected maps any unexpected throw to our error type
      catchUnexpected: (cause) => ({ type: 'UNEXPECTED', cause }),
    }
  );
}

// Happy path
const success = await handleError({ taskId: 't-1' }, { db });
// success.ok === true, success.value.status === 'Errored'

// Error path: exits early, never calls markErrored
const failure = await handleError({ taskId: 't-missing' }, { db });
// failure.ok === false, failure.error.type === 'TASK_NOT_FOUND'
```

The happy path now reads linearly (no if-checks), errors automatically short-circuit, and `step()` unwraps the value so you can use it directly.

Important API note: with `run()`, you use `step(id, () => fn())` — an id (string) and a thunk. The thunk lets `run()` catch synchronous throws.

### Step 10: step.try() for Throwing Code

Third-party SDKs (Slack, Stripe, database drivers) throw exceptions. Use `step.try()` to wrap them into typed errors:

```ts
type ConnectFailed = { type: 'CONNECT_FAILED'; message: string };
type TaskNotFound = { type: 'TASK_NOT_FOUND' };
type Unexpected = { type: 'UNEXPECTED'; cause: unknown };
type StepError = ConnectFailed | TaskNotFound | Unexpected;

// Simulate an SDK that throws
const unstableDb = {
  connect: async () => {
    throw new Error('Connection refused');
  },
};

async function handleWithUnstableDb(): AsyncResult<string, StepError> {
  return run<string, StepError>(
    async (step) => {
      // step.try(id, thunk, opts) catches the throw and converts to typed error
      const db = await step.try('connect', () => unstableDb.connect(), {
        onError: (e): ConnectFailed => ({
          type: 'CONNECT_FAILED',
          message: e instanceof Error ? e.message : 'Unknown',
        }),
      });

      // This line never runs because connect() threw
      const task = await db.findTask();
      return task ? 'success' : 'no-task';
    },
    {
      catchUnexpected: (cause) => ({ type: 'UNEXPECTED', cause }),
    }
  );
}

const result = await handleWithUnstableDb();
// result.ok === false
// result.error.type === 'CONNECT_FAILED'
// result.error.message === 'Connection refused'
```

Use `step.try()` to wrap specific calls you know can throw (SDKs, I/O). Use `catchUnexpected` as a safety net for bugs and unexpected throws.

Prefer `step.try()` for known throwers; rely on `catchUnexpected` as a fallback for genuine surprises.

## Part 4: Workflows

`createWorkflow` keeps the same mental model as `run()` but adds automatic error inference.

### Step 11: createWorkflow for Automatic Error Inference

With `run()`, you manually maintain the error union:

```ts
type HandlerError = TaskNotFound | DbFailed | SlackFailed | Unexpected;
return run<TaskWithError, HandlerError>(...);
```

With `createWorkflow`, the error union is computed from your deps:

```ts
import { createWorkflow } from 'awaitly/workflow';

type TaskNotFound = { type: 'TASK_NOT_FOUND'; taskId: string };
type DbFailed = { type: 'DB_FAILED'; message: string };
type SlackFailed = { type: 'SLACK_FAILED'; message: string };

const loadTask = async (taskId: string): AsyncResult<Task, TaskNotFound> => {
  const task = taskStore.get(taskId);
  if (!task) return err({ type: 'TASK_NOT_FOUND', taskId });
  return ok(task);
};

const markErrored = async (taskId: string): AsyncResult<Task, DbFailed> => {
  // ... implementation
};

const notifySlack = async (taskId: string): AsyncResult<void, SlackFailed> => {
  // ... implementation
};

// createWorkflow: declare deps, get automatic error inference
const errorHandler = createWorkflow({ loadTask, markErrored, notifySlack });

// TypeScript INFERS the error type from deps:
// Error = TaskNotFound | DbFailed | SlackFailed | UnexpectedError
// No manual "type HandlerError = ..." needed!

const result = await errorHandler(async (step, deps) => {
  const task = await step('loadTask', () => deps.loadTask('t-1'));
  const updated = await step('markErrored', () => deps.markErrored(task._id));
  await step('notifySlack', () => deps.notifySlack(updated._id));
  return updated;
});
```

No manual union maintenance (add a dep and the error type is auto-included), `UnexpectedError` is included by default (no `catchUnexpected` needed), and each step uses `step(id, () => deps.fn())` — an id plus a thunk so the workflow can cache and track steps.

API note: both `run()` and workflow use `step(id, () => fn())`. The id identifies the step for observability and caching; use `step(id, () => deps.fn(), { key })` when you need an explicit cache key.

### Step 12: Boundary Mapping with UnexpectedError

At your Lambda boundary, map the Result to HTTP responses:

```ts
import { createWorkflow, UNEXPECTED_ERROR } from 'awaitly/workflow';

type LambdaResponse =
  | { statusCode: 200; body: { message: string; task: Task } }
  | { statusCode: 404; body: { message: string; taskId: string } }
  | { statusCode: 500; body: { message: string } };

const errorHandler = createWorkflow({ loadTask, markErrored });

async function lambdaHandler(event: { taskId: string }): Promise<LambdaResponse> {
  const result = await errorHandler(async (step, deps) => {
    const task = await step('loadTask', () => deps.loadTask(event.taskId));
    return await step('markErrored', () => deps.markErrored(task._id));
  });

  if (result.ok) {
    return { statusCode: 200, body: { message: 'Success', task: result.value } };
  }

  // Error type is: TaskNotFound | DbFailed | UnexpectedError
  // TypeScript ensures we handle all cases (including UnexpectedError!)
  switch (result.error.type) {
    case 'TASK_NOT_FOUND':
      return { statusCode: 404, body: { message: 'Task not found', taskId: result.error.taskId } };
    case 'DB_FAILED':
      return { statusCode: 500, body: { message: `DB error: ${result.error.message}` } };
    case UNEXPECTED_ERROR:
      // UnexpectedError is included automatically by workflow
      return { statusCode: 500, body: { message: 'Internal server error' } };
  }
}
```

Key difference from run(): the workflow error type includes `UnexpectedError` automatically. You must handle `UnexpectedError` in your switch (TypeScript will remind you). Use the `UNEXPECTED_ERROR` constant to ensure you can't drift from the library.

### Step 13: Step Names and Observability

Add name/key options to steps for observability:

```ts
const workflow = createWorkflow(
  { loadTask },
  {
    onEvent: (event) => {
      const name = 'name' in event ? event.name : undefined;
      console.log(`${event.type}:${name ?? 'anonymous'}`);
    },
  }
);

const result = await workflow(async (step, deps) => {
  // Step id shows up in events; key enables caching
  return await step('loadTask', () => deps.loadTask('t-1'), {
    key: 'task:t-1',  // Same key = cached result
  });
});

// Console output:
// step_start:loadTask
// step_complete:loadTask
```

Step ids show up in logs/traces, keys enable caching (same key = cached result), and this is the foundation for durable execution (resume from last step).

## Part 5: Full Example

The complete refactored handler using `createWorkflow`:

```ts
import { ok, err, tryAsync, type AsyncResult } from 'awaitly';
import { createWorkflow, UNEXPECTED_ERROR } from 'awaitly/workflow';

// ═══════════════════════════════════════════════════════════════════════════
// TYPES
// ═══════════════════════════════════════════════════════════════════════════

type Task = { _id: string; status: 'Pending' | 'Errored'; error?: { formattedLogs: string[] } };

type TaskNotFound = { type: 'TASK_NOT_FOUND'; taskId: string };
type DbFailed = { type: 'DB_FAILED'; message: string };
type SlackFailed = { type: 'SLACK_FAILED'; message: string };

type LambdaResponse =
  | { statusCode: 200; body: { message: string; task: Task } }
  | { statusCode: 404; body: { message: string; taskId: string } }
  | { statusCode: 500; body: { message: string } };

// ═══════════════════════════════════════════════════════════════════════════
// BUSINESS OPERATIONS (each returns AsyncResult)
// ═══════════════════════════════════════════════════════════════════════════

const loadTask = async (taskId: string): AsyncResult<Task, TaskNotFound> => {
  const task = taskStore.get(taskId);
  if (!task) return err({ type: 'TASK_NOT_FOUND', taskId });
  return ok(task);
};

const getLogs = async (taskId: string): AsyncResult<string[], DbFailed> => {
  return tryAsync(
    async () => logStore.get(taskId) ?? [],
    (e) => ({ type: 'DB_FAILED', message: e instanceof Error ? e.message : 'Unknown' })
  );
};

const markErrored = async (
  taskId: string,
  formattedLogs: string[]
): AsyncResult<Task, DbFailed> => {
  const task = taskStore.get(taskId);
  if (!task) return err({ type: 'DB_FAILED', message: 'Not found' });
  const updated: Task = { ...task, status: 'Errored', error: { formattedLogs } };
  taskStore.set(taskId, updated);
  return ok(updated);
};

const notifySlack = async (slack: SlackClient, task: Task): AsyncResult<void, SlackFailed> => {
  return tryAsync(
    async () => await slack.post({ title: `Task ${task._id} errored` }),
    (e) => ({ type: 'SLACK_FAILED', message: e instanceof Error ? e.message : 'Unknown' })
  );
};

// Pure function: no deps needed
const formatLogs = (logs: string[]) => logs.map((l) => `• ${l}`);

// ═══════════════════════════════════════════════════════════════════════════
// WORKFLOW (orchestrates operations)
// ═══════════════════════════════════════════════════════════════════════════

// Error type is INFERRED: TaskNotFound | DbFailed | SlackFailed | UnexpectedError
const errorHandler = createWorkflow({ loadTask, getLogs, markErrored, notifySlack });

// ═══════════════════════════════════════════════════════════════════════════
// LAMBDA HANDLER (boundary)
// ═══════════════════════════════════════════════════════════════════════════

async function lambdaHandler(
  event: { taskId: string },
  slack: SlackClient,
  env: { production: boolean }
): Promise<LambdaResponse> {
  const result = await errorHandler(async (step, deps) => {
    const task = await step('loadTask', () => deps.loadTask(event.taskId));
    const logs = await step('getLogs', () => deps.getLogs(task._id));
    const formattedLogs = formatLogs(logs);
    const updated = await step('markErrored', () => deps.markErrored(task._id, formattedLogs));

    if (env.production) {
      await step('notifySlack', () => deps.notifySlack(slack, updated));
    }

    return updated;
  });

  if (result.ok) {
    return { statusCode: 200, body: { message: 'Error handled successfully', task: result.value } };
  }

  // Handle all error types (including UnexpectedError from workflow)
  switch (result.error.type) {
    case 'TASK_NOT_FOUND':
      return { statusCode: 404, body: { message: 'Task not found', taskId: result.error.taskId } };
    case 'DB_FAILED':
    case 'SLACK_FAILED':
    case UNEXPECTED_ERROR:
      return { statusCode: 500, body: { message: 'Internal server error' } };
  }
}
```

Compare to original:

| Before | After |
| --- | --- |
| try/catch everywhere | Typed Results throughout |
| deps created inline | deps injected at boundary |
| errors as strings | errors are data with context |
| manual error union maintenance | automatic inference with createWorkflow |
| happy path buried in if-checks | linear happy path with step() |

## Appendix: Convenience Helpers

These helpers reduce boilerplate but aren't core to understanding Awaitly. Learn the core concepts first (`ok`/`err`/`run`/`workflow`), then use these for cleaner code.

### tryAsync: Wrap Throwing Code

```ts
import { tryAsync } from 'awaitly';

type DbError = { type: 'DB_ERROR'; message: string };

// Manual try/catch
async function updateTaskManual(id: string): AsyncResult<{ id: string }, DbError> {
  try {
    const result = await unstableDb.update(id);
    return ok(result);
  } catch (e) {
    return err({ type: 'DB_ERROR', message: e instanceof Error ? e.message : 'Unknown' });
  }
}

// Using tryAsync: much cleaner
async function updateTaskClean(id: string): AsyncResult<{ id: string }, DbError> {
  return tryAsync(
    async () => await unstableDb.update(id),
    (e) => ({ type: 'DB_ERROR', message: e instanceof Error ? e.message : 'Unknown' })
  );
}
```

Use this when wrapping SDK calls (Slack, Stripe, database drivers) that throw exceptions.

### fromNullable: Convert null to Typed Error

```ts
import { fromNullable } from 'awaitly';

type TaskNotFound = { type: 'TASK_NOT_FOUND'; taskId: string };

// Manual null check
async function getTaskManual(taskId: string): AsyncResult<Task, TaskNotFound> {
  const task = taskStore.get(taskId);
  if (!task) return err({ type: 'TASK_NOT_FOUND', taskId });
  return ok(task);
}

// Using fromNullable: one line
async function getTaskClean(taskId: string): AsyncResult<Task, TaskNotFound> {
  const task = taskStore.get(taskId);
  return fromNullable(task, () => ({ type: 'TASK_NOT_FOUND', taskId }));
}
```

Use this for database queries that return null for "not found", or optional properties that should be required.

### Errors<> and ErrorOf<>: Extract Error Types

```ts
import { type Errors, type ErrorOf } from 'awaitly';

// ErrorOf extracts the error type from a single function
type TaskNotFoundExtracted = ErrorOf<typeof loadTask>;
// Type: { type: 'TASK_NOT_FOUND'; taskId: string }

// Errors extracts and unions error types from multiple functions
type HandlerErrorExtracted = Errors<[typeof loadTask, typeof markErrored]>;
// Type: TaskNotFound | DbFailed

// Instead of manually writing:
type HandlerErrorManual = TaskNotFound | DbFailed;
```

Use this when building error unions from multiple functions, or reducing boilerplate in error type definitions.

## Appendix: run() vs createWorkflow

When to prefer `run()`:
- You want explicit control over the error union
- You're teaching or learning the fundamentals
- Simple cases with 1-2 steps

When to prefer `createWorkflow`:
- Multiple steps with multiple error types
- You want automatic error inference
- You need observability (onEvent)
- You want built-in `UnexpectedError` handling

```ts
// run(): manual error union
type HandlerError = TaskNotFound | DbFailed | Unexpected;
return run<Task, HandlerError>(
  async (step) => {
    const task = await step('loadTask', () => loadTask({ taskId }));
    return await step('markErrored', () => markErrored({ taskId: task._id }));
  },
  { catchUnexpected: (cause) => ({ type: 'UNEXPECTED', cause }) }
);

// createWorkflow: automatic error union
const workflow = createWorkflow({ loadTask, markErrored });
return workflow(async (step, deps) => {
  const task = await step('loadTask', () => deps.loadTask(taskId));
  return await step('markErrored', () => deps.markErrored(task._id));
});
// Error type is inferred: TaskNotFound | DbFailed | UnexpectedError
```

## Try It

```bash
npm install awaitly
```

Start with `ok()` and `err()`. Move to `run()` when you have multiple steps. Graduate to `createWorkflow` when you want automatic error inference.

Your future self (and your on-call colleagues) will thank you.

Repo: [github.com/jagreehal/awaitly](https://github.com/jagreehal/awaitly)