async function transfer(id: string, amt: number) {
try {
const user = await getUser(id);
if (!user) return { error: 'not found' };
if (user.balance < amt)
return { error: 'insufficient' };
return await charge(user, amt);
} catch (e) {
// e: unknown — network? db? sdk crash?
return { error: String(e) };
}
}awaitly
Errors as data,in async/await.
awaitly returns errors as data, infers their types from your dependencies, and composes async steps without try/catch. The familiar syntax you already write — with the safety you wish it had.
npm install awaitlyjagreehal/awaitlySame handler. One returns unknown.
Both versions handle a money transfer that can fail four different ways. On the left, every error funnels to error: unknown — TypeScript can't help. On the right, every error is named, typed, and unionised by inference.
const transfer = createWorkflow({ getUser, charge, checkFunds });
const result = await transfer(async ({ step, deps }) => {
const user = await step('getUser', () => deps.getUser(id));
await step('checkFunds', () => deps.checkFunds(user, amt));
return await step('charge', () => deps.charge(user, amt));
});
if (!result.ok) {
switch (result.error.type) {
case 'USER_NOT_FOUND': return 404;
case 'INSUFFICIENT_FUNDS': return 400;
case 'CHARGE_DECLINED': return 402;
} // exhaustive — TS errors if you miss one
}Steps unwrap. Errors short-circuit.
Every dependency that can fail returns a Result. step()either hands you the value and continues, or exits the workflow with the error. Your happy path stays linear. The error union grows, automatically, with each step you add.
getOrder- ORDER_NOT_FOUND
getUser- USER_NOT_FOUND
charge- CHARGE_DECLINED
- INSUFFICIENT_FUNDS
Add step.retry(), step.withTimeout(), or step('id', fn, { key })and the same shape carries — TypeScript still tracks every error your dependencies can produce.
Three ideas. That's the whole library.
Errors as data.
Functions that fail return Result types — never thrown exceptions for expected failures. The compiler tracks every variant.
// expected failures become typed valuesreturn ok({ id, name });
return err({ type: 'USER_NOT_FOUND', userId });
Composes with await.
step() unwraps Results inside a normal async function. The happy path reads top-to-bottom, errors short-circuit the rest.
const user = await step('getUser', () => deps.getUser(id));
const order = await step('getOrder', () => deps.getOrder(user.id));
return fulfil(order);
Reliability built in.
Retries, timeouts, idempotency keys, save & resume — primitives, not afterthoughts. Add them where you need them, leave the rest untouched.
await step.retry('charge', () => deps.charge(amt), {
attempts: 3,
backoff: 'exponential',
key: `charge:${order.idempotencyKey}`
});
Honest about the trade-offs.
Effect is excellent. neverthrow is excellent. try/catch works fine when types don't matter. awaitly sits in the middle: typed errors, async/await, no DSL to learn.
| awaitlythis library | try / catchJavaScript | neverthrowResult types | Effectfp ecosystem | |
|---|---|---|---|---|
| async / await syntax | ● | ● | ◐methods | ◐gens / pipe |
| errors typed automatically | ● | ○unknown | ◐manual unions | ● |
| retries / timeouts built in | ● | ○ | ○ | ● |
| pause / resume workflows | ● | ○ | ○ | ◐via fibers |
| learning curve | one new fn | none | low | runtime + DSL |
Async code your compiler can typecheck.
Five minutes to your first typed workflow. Just async/await, Result types, and inferred error unions.