Error Patterns
Result types are a powerful tool for modeling expected outcomes, but using them everywhere creates noise without benefit. awaitly is designed so you only model expected domain errors as typed Results — unexpected exceptions are caught automatically and wrapped as UnexpectedError with the original exception preserved in cause. This page shows patterns to follow and patterns to avoid.
Three classes of errors
Section titled “Three classes of errors”Not every error deserves a type. Errors fall into three classes, and each one calls for a different approach:
| Class | What it is | How awaitly handles it |
|---|---|---|
| Domain errors | Expected business failures — validation, not-found, insufficient funds | You model these as typed errors with err(). Result is the right tool. |
| Panics | Programmer errors, out-of-memory, null references | Let them throw. run, createWorkflow, and saga all wrap these as UnexpectedError with the original exception in cause. |
| Infrastructure errors | Network timeouts, auth failures, disk I/O | Case-by-case. Model the ones your domain branches on. Let the rest become UnexpectedError. |
The patterns below follow from this classification.
Patterns to avoid
Section titled “Patterns to avoid”Don’t wrap every exception in Result
Section titled “Don’t wrap every exception in Result”awaitly already catches unexpected throws in run(), createWorkflow(), and saga(). Wrapping them yourself adds noise and hides the real exception.
// ❌ Manually catching and wrapping — redundant, loses the real stack traceconst result = await workflow.run(async ({ step, deps }) => { try { return await step('fetchUser', () => deps.fetchUser('123')); } catch (e) { return err('UNEXPECTED'); }});
// ✅ Let it throw — awaitly wraps it as UnexpectedError with causeconst result = await workflow.run(async ({ step, deps }) => { return await step('fetchUser', () => deps.fetchUser('123'));});
// The original exception is preserved:if (!result.ok && isUnexpectedError(result.error)) { console.error(result.error.cause); // Original Error with stack trace}Don’t use Result when you should fail fast
Section titled “Don’t use Result when you should fail fast”If your app can’t continue without a config file or database connection, don’t return a Result — throw at startup. Returning err() delays the inevitable and obscures the failure.
// ❌ Returning a Result for something that should halt the processconst loadConfig = (): AsyncResult<Config, 'CONFIG_MISSING'> => { const raw = process.env.DATABASE_URL; if (!raw) return err('CONFIG_MISSING'); return ok({ databaseUrl: raw });};
// Then deep in a workflow:const config = await step('loadConfig', () => loadConfig());// The workflow keeps running, but nothing after this will work.
// ✅ Throw at startup, before any workflow runsfunction loadConfig(): Config { const raw = process.env.DATABASE_URL; if (!raw) throw new Error('DATABASE_URL is required'); return { databaseUrl: raw };}
const config = loadConfig(); // Fails immediately if missingconst workflow = createWorkflow('checkout', { /* deps using config */ });Don’t model every possible I/O error
Section titled “Don’t model every possible I/O error”Only model the errors your domain logic actually branches on. Trying to represent every possible failure in a union type creates busywork with no benefit.
// ❌ Modeling every possible file-system errortype FileError = | 'FILE_NOT_FOUND' | 'DIRECTORY_NOT_FOUND' | 'FILE_NOT_ACCESSIBLE' | 'PATH_TOO_LONG' | 'DISK_FULL' | 'OTHER_IO_ERROR';
const readTemplate = async (path: string): AsyncResult<string, FileError> => { // ...};
// ✅ Model only what the domain cares aboutconst readTemplate = async (path: string): AsyncResult<string, 'TEMPLATE_NOT_FOUND'> => { try { return ok(await fs.readFile(path, 'utf-8')); } catch { return err('TEMPLATE_NOT_FOUND'); }};// If the disk is full or the path is invalid, that's a panic —// let it throw and become UnexpectedError with the real exception in cause.Don’t use Result if no one checks the error cases
Section titled “Don’t use Result if no one checks the error cases”If every consumer just checks result.ok and never branches on specific error types, a rich error union is overhead. Keep it simple.
// ❌ Rich error type that no consumer ever inspectsconst enrichProfile = async ( id: string): AsyncResult<Profile, 'API_TIMEOUT' | 'RATE_LIMITED' | 'MALFORMED_RESPONSE' | 'SERVICE_DOWN'> => { // ...};
// Every caller does the same thing:const profile = await step('enrich', () => deps.enrichProfile(id));if (!result.ok) { logger.warn('Enrichment failed, continuing without it');}
// ✅ Simple error type — callers don't distinguish between failure reasonsconst enrichProfile = async (id: string): AsyncResult<Profile, 'ENRICHMENT_FAILED'> => { // ...};Patterns to follow
Section titled “Patterns to follow”Use Result for expected domain errors
Section titled “Use Result for expected domain errors”Validation failures, business rule violations, and not-found are expected outcomes that callers need to branch on. This is exactly what Result is for — a glorified boolean with extra information, not a replacement for exceptions.
// ✅ Domain errors that callers handle differentlyconst checkout = async ( cart: Cart, payment: PaymentMethod): AsyncResult<Order, 'CART_EMPTY' | 'INSUFFICIENT_FUNDS' | 'ITEM_OUT_OF_STOCK'> => { if (cart.items.length === 0) return err('CART_EMPTY'); if (payment.balance < cart.total) return err('INSUFFICIENT_FUNDS'); // ... return ok(order);};
// Caller branches on each case — this is where Result shinesconst result = await workflow.run(async ({ step, deps }) => { return await step('checkout', () => deps.checkout(cart, payment));});
if (!result.ok) { switch (result.error) { case 'CART_EMPTY': return res.status(400).json({ error: 'Cart is empty' }); case 'INSUFFICIENT_FUNDS': return res.status(402).json({ error: 'Insufficient funds' }); case 'ITEM_OUT_OF_STOCK': return res.status(409).json({ error: 'Item out of stock' }); }}Use step.try to convert throwing code at boundaries
Section titled “Use step.try to convert throwing code at boundaries”Third-party libraries throw exceptions. Wrap them at the boundary with step.try so the exception becomes a typed error inside your workflow.
// ✅ Convert throwing code into a typed Result at the boundaryconst result = await workflow.run(async ({ step }) => { const data = await step.try( 'parseInput', () => JSON.parse(rawInput), { error: 'INVALID_JSON' as const } );
const token = await step.try( 'verify', () => jwt.verify(data.token, secret), { error: 'INVALID_TOKEN' as const } );
return token;});// result.error is: 'INVALID_JSON' | 'INVALID_TOKEN' | UnexpectedErrorLet UnexpectedError preserve diagnostics for you
Section titled “Let UnexpectedError preserve diagnostics for you”UnexpectedError keeps the original exception in cause. You get full stack traces for debugging without cluttering your domain model with infrastructure concerns.
// ✅ Log the real exception, act on the domain errorimport { isUnexpectedError } from 'awaitly';
const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('123')); await step('sendWelcome', () => deps.sendEmail(user.email)); return user;});
if (!result.ok) { if (isUnexpectedError(result.error)) { // Infrastructure failure — log and return 500 console.error('Unexpected failure:', result.error.cause); // Original Error + stack trace return res.status(500).json({ error: 'Internal error' }); }
// Domain error — handle normally switch (result.error) { case 'NOT_FOUND': return res.status(404).json({ error: 'User not found' }); case 'EMAIL_FAILED': return res.status(502).json({ error: 'Email service unavailable' }); }}How awaitly keeps you safe
Section titled “How awaitly keeps you safe”run(), createWorkflow(), and saga() all catch thrown exceptions automatically and wrap them as UnexpectedError with the original exception in cause. You never lose stack traces. You never need to model every possible failure. Your typed error union stays clean — only the domain errors you actually care about.
If you need to replace UnexpectedError with your own type, pass catchUnexpected to run() or createWorkflow(). See Custom unexpected errors.
Further reading
Section titled “Further reading”awaitly docs:
- Errors and Retries — how error propagation, retries, and timeouts work
- Tagged Errors — structured error types with exhaustive matching
- awaitly vs try/catch — side-by-side comparison with traditional error handling
External:
- Against Railway-Oriented Programming — Scott Wlaschin on when Result types are the wrong tool
- You’re better off using Exceptions — Eirik Tsarpalis on Result types as a general-purpose error mechanism