Framework Integration
awaitly integrates naturally with popular frameworks. This guide shows common patterns for React, Next.js, Express, and Fastify.
useWorkflow hook
Section titled “useWorkflow hook”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> );}Error boundaries
Section titled “Error boundaries”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; }}
// Usagefunction App() { return ( <WorkflowErrorBoundary fallback={(error) => ( <div> {isUnexpectedError(error) ? 'An unexpected error occurred' : 'Something went wrong'} </div> )} > <CheckoutPage /> </WorkflowErrorBoundary> );}Next.js
Section titled “Next.js”Server Actions
Section titled “Server Actions”Use awaitly in Next.js 13+ server actions:
'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> );}API Routes
Section titled “API Routes”Use awaitly in Next.js API routes:
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 Router
Section titled “Pages Router”API handler pattern
Section titled “API handler pattern”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' }); }}Express
Section titled “Express”Middleware pattern
Section titled “Middleware pattern”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 responsesfunction 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); } };}
// Usageconst 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 }; });}));Route handlers with typed errors
Section titled “Route handlers with typed errors”import { type AsyncResult, createWorkflow } from 'awaitly';import { Router } from 'express';
const router = Router();
// POST /usersrouter.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' }); }});Fastify
Section titled “Fastify”Plugin pattern
Section titled “Plugin pattern”import Fastify from 'fastify';import { type Result, type AnyResultFn, type WorkflowFn, createWorkflow,} from 'awaitly';
const fastify = Fastify();
// Decorate request with workflow helperfastify.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 helperfastify.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 });});Schema validation integration
Section titled “Schema validation integration”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 });});Router with workflows
Section titled “Router with workflows”import { z } from 'zod';import { TRPCError } from '@trpc/server';import { router, protectedProcedure } from '../trpc';import { ok, err, type AsyncResult, createWorkflow } from 'awaitly';
// Operationsconst 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 TRPCErrorfunction 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); }),});Client usage
Section titled “Client usage”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> );}Middleware pattern
Section titled “Middleware pattern”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);}Route handler
Section titled “Route handler”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;Best Practices
Section titled “Best Practices”Error mapping
Section titled “Error mapping”Create consistent error-to-HTTP mappings:
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' };}Request context
Section titled “Request context”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'], }), });Next Steps
Section titled “Next Steps”- Testing Guide - Test your workflows
- Production Deployment - Deploy with confidence
- Observability - Add tracing and monitoring