Stop Throwing Errors. Awaitly Makes Async Error Handling Actually Work
22 Jan 2026
We've all written this code:
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 fixes this by treating errors as data, not explosions. This guide teaches the patterns one concept at a time.
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:
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.
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:
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:
// 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:
// 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:
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:
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):
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:
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:
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:
// 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:
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.
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:
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:
type HandlerError = TaskNotFound | DbFailed | SlackFailed | Unexpected;
return run<TaskWithError, HandlerError>(...);
With createWorkflow, the error union is computed from your deps:
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:
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:
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:
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
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
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
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
UnexpectedErrorhandling
// 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
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.