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 } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';
type WorkflowState<T, E> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: E | UnexpectedError };
export function useWorkflow<T, E, Deps extends Record<string, unknown>>( deps: Deps, workflowFn: (step: any, deps: Deps) => Promise<T>) { 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 } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';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/workflow';
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 });}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 } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';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': // User created but email failed - still success return res.status(201).json({ ...result.value, 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 } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';
const fastify = Fastify();
// Decorate request with workflow helperfastify.decorateRequest('workflow', null);
fastify.addHook('preHandler', async (request) => { request.workflow = <T, E, Deps extends Record<string, unknown>>( deps: Deps, fn: (step: any, deps: Deps) => Promise<T> ) => { 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 });});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