Skip to content

awaitly

awaitly · v1.30

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
01 · The diff

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.

today try / catch
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) };
  }
}
TypeScript sees { error: string } | Charge

Three different failures collapse into one string. The boundary can't tell not found from declined.

with awaitly step() + Result
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
}
TypeScript infers Result<Charge, USER_NOT_FOUND | INSUFFICIENT_FUNDS | CHARGE_DECLINED | UnexpectedError>

Add a step? The error union widens automatically. Remove one? It narrows. The compiler keeps it honest.

02 · Mental model

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.

Add step.retry(), step.withTimeout(), or step('id', fn, { key }) and the same shape carries — TypeScript still tracks every error your dependencies can produce.

03 · Why awaitly

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}`
});
04 · Where it fits

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
Start writing it

Async code your compiler can typecheck.

Five minutes to your first typed workflow. Just async/await, Result types, and inferred error unions.