Skip to content

Framework Integration

awaitly integrates naturally with popular frameworks. This guide shows common patterns for React, Next.js, Express, and Fastify.

Create a reusable hook for running workflows in components:

import { useState, useCallback } from 'react';
import {
type Result,
type UnexpectedError,
type AnyResultFn,
type WorkflowFn,
createWorkflow,
} from 'awaitly';
type WorkflowState<T, E> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E | UnexpectedError };
// The workflow function receives one context object, not positional args.
export function useWorkflow<T, E, Deps extends Record<string, AnyResultFn>>(
deps: Deps,
workflowFn: WorkflowFn<T, E, Deps>
) {
const [state, setState] = useState<WorkflowState<T, E>>({ status: 'idle' });
const run = useCallback(async () => {
setState({ status: 'loading' });
const workflow = createWorkflow('workflow', deps);
const result = await workflow.run(workflowFn);
if (result.ok) {
setState({ status: 'success', data: result.value });
} else {
setState({ status: 'error', error: result.error as E | UnexpectedError });
}
return result;
}, [deps, workflowFn]);
return { ...state, run };
}

Usage in a component:

function CheckoutButton({ cartId }: { cartId: string }) {
const { status, data, error, run } = useWorkflow(
{ validateCart, processPayment, sendConfirmation },
async ({ step, deps }) => {
const cart = await step('validateCart', () => deps.validateCart(cartId));
const payment = await step('processPayment', () => deps.processPayment(cart.total));
await step('sendConfirmation', () => deps.sendConfirmation(cart.email, payment.id));
return { orderId: payment.id };
}
);
return (
<div>
<button onClick={run} disabled={status === 'loading'}>
{status === 'loading' ? 'Processing...' : 'Checkout'}
</button>
{status === 'error' && <p>Error: {String(error)}</p>}
{status === 'success' && <p>Order confirmed: {data.orderId}</p>}
</div>
);
}

Integrate with React error boundaries for unexpected errors:

import { Component, type ReactNode } from 'react';
import { isUnexpectedError } from 'awaitly';
interface Props {
children: ReactNode;
fallback: (error: unknown) => ReactNode;
}
interface State {
error: unknown | null;
}
export class WorkflowErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: unknown): State {
return { error };
}
render() {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<WorkflowErrorBoundary
fallback={(error) => (
<div>
{isUnexpectedError(error)
? 'An unexpected error occurred'
: 'Something went wrong'}
</div>
)}
>
<CheckoutPage />
</WorkflowErrorBoundary>
);
}

Use awaitly in Next.js 13+ server actions:

app/actions/checkout.ts
'use server';
import { type AsyncResult, createWorkflow } from 'awaitly';
import { validateCart, processPayment, sendEmail } from '@/lib/services';
type CheckoutResult = { orderId: string };
type CheckoutError = 'INVALID_CART' | 'PAYMENT_FAILED' | 'EMAIL_FAILED';
export async function checkout(
cartId: string
): Promise<AsyncResult<CheckoutResult, CheckoutError>> {
const workflow = createWorkflow('workflow', { validateCart,
processPayment,
sendEmail,
});
return await workflow.run(async ({ step, deps }) => {
const cart = await step('validateCart', () => deps.validateCart(cartId));
const payment = await step('processPayment', () => deps.processPayment(cart.total));
await step('sendEmail', () => deps.sendEmail(cart.email, payment.receiptUrl));
return { orderId: payment.id };
});
}

Use in a client component:

'use client';
import { checkout } from './actions/checkout';
export function CheckoutForm({ cartId }: { cartId: string }) {
const [pending, setPending] = useState(false);
async function handleSubmit() {
setPending(true);
const result = await checkout(cartId);
setPending(false);
if (result.ok) {
redirect(`/order/${result.value.orderId}`);
} else {
// Handle typed errors
switch (result.error) {
case 'INVALID_CART':
toast.error('Your cart is invalid');
break;
case 'PAYMENT_FAILED':
toast.error('Payment failed, please try again');
break;
case 'EMAIL_FAILED':
toast.warning('Order placed but confirmation email failed');
break;
}
}
}
return (
<button onClick={handleSubmit} disabled={pending}>
{pending ? 'Processing...' : 'Complete Order'}
</button>
);
}

Use awaitly in Next.js API routes:

app/api/orders/route.ts
import { NextResponse } from 'next/server';
import { createWorkflow } from 'awaitly';
export async function POST(request: Request) {
const body = await request.json();
const workflow = createWorkflow('workflow', { validateOrder, chargeCard, createOrder });
const result = await workflow.run(async ({ step, deps }) => {
const validated = await step('validateOrder', () => deps.validateOrder(body));
const charge = await step('chargeCard', () => deps.chargeCard(validated.amount));
const order = await step('createOrder', () => deps.createOrder(validated, charge.id));
return order;
});
if (result.ok) {
return NextResponse.json(result.value, { status: 201 });
}
// Map errors to HTTP responses
const errorMap: Record<string, number> = {
VALIDATION_ERROR: 400,
CARD_DECLINED: 402,
INVENTORY_ERROR: 409,
};
const status = errorMap[String(result.error)] ?? 500;
return NextResponse.json({ error: result.error }, { status });
}
pages/api/orders/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { ok, err, type AsyncResult, createWorkflow } from 'awaitly';
const fetchOrder = async (id: string): AsyncResult<Order, 'NOT_FOUND'> => {
const order = await db.orders.findUnique({ where: { id } });
return order ? ok(order) : err('NOT_FOUND');
};
const checkOwnership = async (order: Order, userId: string): AsyncResult<void, 'FORBIDDEN'> => {
return order.userId === userId ? ok(undefined) : err('FORBIDDEN');
};
const orderWorkflow = createWorkflow('workflow', { fetchOrder, checkOwnership });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { id } = req.query;
const userId = req.headers['x-user-id'] as string;
const result = await orderWorkflow.run(async ({ step, deps }) => {
const order = await step('fetchOrder', () => deps.fetchOrder(id as string));
await step('checkOwnership', () => deps.checkOwnership(order, userId));
return order;
});
if (result.ok) {
return res.status(200).json(result.value);
}
switch (result.error) {
case 'NOT_FOUND':
return res.status(404).json({ error: 'Order not found' });
case 'FORBIDDEN':
return res.status(403).json({ error: 'Access denied' });
default:
return res.status(500).json({ error: 'Internal error' });
}
}

Create middleware for consistent error handling:

import express, { type Request, type Response, type NextFunction } from 'express';
import { type Result, isUnexpectedError } from 'awaitly';
// Middleware to handle Result responses
function resultHandler<T, E>(
handler: (req: Request) => Promise<Result<T, E>>
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await handler(req);
if (result.ok) {
res.json(result.value);
} else {
// Map domain errors to HTTP status codes
const error = result.error;
if (isUnexpectedError(error)) {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(400).json({ error });
}
}
} catch (error) {
next(error);
}
};
}
// Usage
const app = express();
app.post('/api/orders', resultHandler(async (req) => {
const workflow = createWorkflow('workflow', { validateOrder, processPayment });
return await workflow.run(async ({ step, deps }) => {
const order = await step('validateOrder', () => deps.validateOrder(req.body));
const payment = await step('processPayment', () => deps.processPayment(order.total));
return { orderId: payment.id };
});
}));
import { type AsyncResult, createWorkflow } from 'awaitly';
import { Router } from 'express';
const router = Router();
// POST /users
router.post('/users', async (req, res) => {
const workflow = createWorkflow('workflow', { validateUser, createUser, sendWelcome });
const result = await workflow.run(async ({ step, deps }) => {
const validated = await step('validateUser', () => deps.validateUser(req.body));
const user = await step('createUser', () => deps.createUser(validated));
await step('sendWelcome', () => deps.sendWelcome(user.email));
return user;
});
if (result.ok) {
return res.status(201).json(result.value);
}
// Type-safe error handling
const error = result.error;
switch (error) {
case 'INVALID_EMAIL':
case 'WEAK_PASSWORD':
return res.status(400).json({ error, message: 'Validation failed' });
case 'USER_EXISTS':
return res.status(409).json({ error, message: 'User already exists' });
case 'EMAIL_FAILED':
// The user was created but the welcome email failed, so the run ended in
// `err` — there is no `result.value` to spread on this branch.
return res.status(201).json({
warning: 'Welcome email could not be sent'
});
default:
return res.status(500).json({ error: 'Internal error' });
}
});
import Fastify from 'fastify';
import {
type Result,
type AnyResultFn,
type WorkflowFn,
createWorkflow,
} from 'awaitly';
const fastify = Fastify();
// Decorate request with workflow helper
fastify.decorateRequest('workflow', null);
fastify.addHook('preHandler', async (request) => {
request.workflow = <T, E, Deps extends Record<string, AnyResultFn>>(
deps: Deps,
fn: WorkflowFn<T, E, Deps>
) => {
const workflow = createWorkflow('workflow', deps);
return workflow.run(fn);
};
});
// Route using the helper
fastify.post('/orders', async (request, reply) => {
const result = await request.workflow.run(
{ validateOrder, processPayment },
async ({ step, deps }) => {
const order = await step('validateOrder', () => deps.validateOrder(request.body));
const payment = await step('processPayment', () => deps.processPayment(order.total));
return { orderId: payment.id };
}
);
if (result.ok) {
return reply.code(201).send(result.value);
}
return reply.code(400).send({ error: result.error });
});
import { Type } from '@sinclair/typebox';
const OrderSchema = Type.Object({
items: Type.Array(Type.Object({
productId: Type.String(),
quantity: Type.Number(),
})),
shippingAddress: Type.String(),
});
fastify.post('/orders', {
schema: {
body: OrderSchema,
},
}, async (request, reply) => {
// Body is already validated by Fastify
const result = await createWorkflow('workflow', { processOrder }).run(async ({ step, deps }) => {
return await step('processOrder', () => deps.processOrder(request.body));
});
if (result.ok) {
return reply.send(result.value);
}
// Only domain errors at this point (validation already passed)
return reply.code(422).send({ error: result.error });
});
server/routers/order.ts
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, protectedProcedure } from '../trpc';
import { ok, err, type AsyncResult, createWorkflow } from 'awaitly';
// Operations
const fetchOrder = async (id: string, userId: string): AsyncResult<Order, 'NOT_FOUND' | 'FORBIDDEN'> => {
const order = await db.orders.findUnique({ where: { id } });
if (!order) return err('NOT_FOUND');
if (order.userId !== userId) return err('FORBIDDEN');
return ok(order);
};
const cancelOrder = async (order: Order): AsyncResult<Order, 'CANNOT_CANCEL'> => {
if (order.status !== 'pending') return err('CANNOT_CANCEL');
const updated = await db.orders.update({
where: { id: order.id },
data: { status: 'cancelled' }
});
return ok(updated);
};
const refundPayment = async (paymentId: string): AsyncResult<void, 'REFUND_FAILED'> => {
const refund = await stripe.refunds.create({ payment_intent: paymentId });
return refund.status === 'succeeded' ? ok(undefined) : err('REFUND_FAILED');
};
const orderWorkflow = createWorkflow('workflow', { fetchOrder, cancelOrder, refundPayment });
// Helper to convert Result errors to TRPCError
function toTRPCError(error: string | { type: string }): TRPCError {
const code = typeof error === 'string' ? error : error.type;
const mapping: Record<string, { code: 'NOT_FOUND' | 'FORBIDDEN' | 'BAD_REQUEST' | 'INTERNAL_SERVER_ERROR'; message: string }> = {
NOT_FOUND: { code: 'NOT_FOUND', message: 'Order not found' },
FORBIDDEN: { code: 'FORBIDDEN', message: 'Access denied' },
CANNOT_CANCEL: { code: 'BAD_REQUEST', message: 'Order cannot be cancelled' },
REFUND_FAILED: { code: 'INTERNAL_SERVER_ERROR', message: 'Refund failed' },
};
const info = mapping[code] ?? { code: 'INTERNAL_SERVER_ERROR', message: 'Unknown error' };
return new TRPCError(info);
}
export const orderRouter = router({
cancel: protectedProcedure
.input(z.object({ orderId: z.string() }))
.mutation(async ({ input, ctx }) => {
const result = await orderWorkflow.run(async ({ step, deps }) => {
const order = await step('fetchOrder', () => deps.fetchOrder(input.orderId, ctx.user.id));
const cancelled = await step('cancelOrder', () => deps.cancelOrder(order));
await step('refundPayment', () => deps.refundPayment(order.paymentId));
return cancelled;
});
if (result.ok) {
return { success: true, order: result.value };
}
throw toTRPCError(result.error);
}),
get: protectedProcedure
.input(z.object({ orderId: z.string() }))
.query(async ({ input, ctx }) => {
const result = await fetchOrder(input.orderId, ctx.user.id);
if (result.ok) {
return result.value;
}
throw toTRPCError(result.error);
}),
});
components/OrderActions.tsx
import { trpc } from '../utils/trpc';
export function OrderActions({ orderId }: { orderId: string }) {
const utils = trpc.useUtils();
const cancelMutation = trpc.order.cancel.useMutation({
onSuccess: () => {
utils.order.get.invalidate({ orderId });
},
});
return (
<button
onClick={() => cancelMutation.mutate({ orderId })}
disabled={cancelMutation.isPending}
>
{cancelMutation.isPending ? 'Cancelling...' : 'Cancel Order'}
</button>
);
}
src/middleware/workflow.ts
import { Context, Next } from 'hono';
import type { Result } from 'awaitly';
type ErrorMapping = Record<string, { status: number; message: string }>;
export function handleWorkflowResult<T>(
result: Result<T, unknown>,
c: Context,
errorMap: ErrorMapping = {}
) {
if (result.ok) {
return c.json(result.value);
}
const errorKey = typeof result.error === 'string'
? result.error
: (result.error as { type?: string })?.type ?? 'UNKNOWN';
const errorInfo = errorMap[errorKey] ?? { status: 500, message: 'Internal error' };
return c.json({ error: errorInfo.message }, errorInfo.status);
}
src/routes/users.ts
import { Hono } from 'hono';
import { ok, err, type AsyncResult, createWorkflow } from 'awaitly';
import { handleWorkflowResult } from '../middleware/workflow';
const app = new Hono();
const validateEmail = async (email: string): AsyncResult<string, 'INVALID_EMAIL'> => {
return email.includes('@') ? ok(email) : err('INVALID_EMAIL');
};
const createUser = async (email: string): AsyncResult<User, 'USER_EXISTS'> => {
const existing = await db.users.findUnique({ where: { email } });
if (existing) return err('USER_EXISTS');
const user = await db.users.create({ data: { email } });
return ok(user);
};
const signupWorkflow = createWorkflow('workflow', { validateEmail, createUser });
app.post('/signup', async (c) => {
const { email } = await c.req.json();
const result = await signupWorkflow.run(async ({ step, deps }) => {
const validEmail = await step('validateEmail', () => deps.validateEmail(email));
const user = await step('createUser', () => deps.createUser(validEmail));
return { userId: user.id };
});
return handleWorkflowResult(result, c, {
INVALID_EMAIL: { status: 400, message: 'Invalid email' },
USER_EXISTS: { status: 409, message: 'Email already registered' },
});
});
export default app;

Create consistent error-to-HTTP mappings:

lib/error-mapper.ts
export const httpErrorMap = new Map<string, { status: number; message: string }>([
['NOT_FOUND', { status: 404, message: 'Resource not found' }],
['UNAUTHORIZED', { status: 401, message: 'Authentication required' }],
['FORBIDDEN', { status: 403, message: 'Access denied' }],
['VALIDATION_ERROR', { status: 400, message: 'Invalid input' }],
['RATE_LIMITED', { status: 429, message: 'Too many requests' }],
]);
export function mapErrorToResponse(error: unknown) {
const mapped = httpErrorMap.get(String(error));
return mapped ?? { status: 500, message: 'Internal server error' };
}

Pass request context through workflows:

const workflow = createWorkflow('workflow', { fetchUser, logAction });
const result = await workflow.run(
async ({ step, deps, ctx }) => {
const user = await step('fetchUser', () => deps.fetchUser(ctx.userId));
await step('logAction', () => deps.logAction(ctx.requestId, 'user_fetched'));
return user;
},
{
createContext: () => ({
userId: req.user.id,
requestId: req.headers['x-request-id'],
}),
}
);