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 awaitly jagreehal/awaitly
Same 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.
| awaitly this library | try / catch JavaScript | neverthrow Result types | Effect fp 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.