Skip to content

awaitly

awaitly

Typed errors inthe async/await you write today.

When a step fails, TypeScript names the error. You keep plain async functions. Retries, timeouts, and resume land on the steps that need them.

npm install awaitly

One function

Return ok or err. Read the error from the type.

Failures belong in the return type, not in a catch block. Handle 'NOT_FOUND' the same way you handle the value.

import { ok, err, type AsyncResult } from 'awaitly';

async function getUser(id: string): AsyncResult<User, 'NOT_FOUND'> {
  const user = await db.find(id);
  return user ? ok(user) : err('NOT_FOUND');
}

const result = await getUser('123');
if (result.ok) use(result.value);
else result.error; // 'NOT_FOUND', typed
TypeScript seesAsyncResult<User, 'NOT_FOUND'>

No throw. No unknown. The error name sits in the type.

Mental model

Call step(). Get the value or stop.

Each dependency returns a Result.step() unwraps it inside your async function. Add a step and the error union grows; remove one and it shrinks.

step.retry(), step.withTimeout(), and idempotency keys use the same shape. The union still reflects your deps.

The diff

Same transfer. One side types the errors.

Both handlers move money and can fail four ways. try/catch leaves you withunknown. awaitly names each failure and unions them in the result type.

todaytry / 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 failures, one string. The handler cannot tellnot found from declined.

with awaitlystep() + Result
import { createWorkflow } from 'awaitly/workflow';
import { isUnexpectedError } from 'awaitly';

const transfer = createWorkflow('transfer', { getUser, charge, checkFunds });

const result = await transfer.run(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) {
  if (isUnexpectedError(result.error)) return 500;
  switch (result.error.type ?? result.error) {
    case 'USER_NOT_FOUND':    return 404;
    case 'INSUFFICIENT_FUNDS': return 400;
    case 'CHARGE_DECLINED':   return 402;
  }
}
TypeScript infersResult<Charge, USER_NOT_FOUND | INSUFFICIENT_FUNDS | CHARGE_DECLINED | UnexpectedError>

Add a step and the union widens. Delete one and it narrows.

What awaitly solves

Three problems, three primitives.

catch (e) is unknown. You read the function body to learn what failed.

Errors as data

  • Return ok or err
  • Errors live in the type
  • Handle by name at the edge
// expected failures become typed values
return ok({ id, name });
return err({ type: 'USER_NOT_FOUND', userId });

You unwrap Results by hand in every async handler.

Composes with await

  • step() inside async fn
  • Happy path reads straight down
  • First error exits the workflow
const user  = await step('getUser', () => deps.getUser(id));
const order = await step('getOrder', () => deps.getOrder(user.id));
return order;

Retries mean another helper library and another config shape.

Reliability built in

  • step.retry() with backoff
  • Timeouts on any step
  • Resume and idempotency keys
await step.retry('charge', () => deps.charge(amt), {
  attempts: 3,
  backoff: 'exponential',
  key: `charge:${order.idempotencyKey}`
});
Where it fits

Between try/catch and a full runtime.

awaitly keeps async/await and infers errors from deps. Effect and neverthrow solve adjacent problems; pick the tool that matches how you write TypeScript today.

awaitlythis librarytry / catchJavaScriptneverthrowResult typesEffectfp ecosystem
async / await syntaxmethodsgens / pipe
errors typed from depsunknownmanual unions
retries / timeouts built in
pause / resume workflowsvia fibers
learning curveone new fnnonelowruntime + DSL
FAQ

Questions we hear often

Still stuck? Open an issue onGitHub.

Why not use Effect?

Effect covers fibers, layers, and a full runtime. awaitly targets teams that want typed errors and workflow primitives without leaving async/await. If you already write Effect, see the Effect layers guide. If you want a lighter entry point, start here.

How long does it take to learn?

Two ideas: return ok/err instead of throwing, then call run() or createWorkflow() to unwrap Results inside an async function. Most teams wire up a first workflow in an afternoon.

Can I adopt it in an existing codebase?

Yes. Wrap one handler that returns Results, call it through run(), and leave the rest on try/catch until you touch those files. No rewrite pass required.

What about runtime overhead?

awaitly adds a thin wrapper around your functions. The cost shows up in clearer types and fewer production surprises, not in extra network hops. Profile your hot paths if latency is tight.

How does it compare to neverthrow?

neverthrow gives you Result types; you maintain error unions by hand. awaitly infers the union from your dependency graph and adds retries, resume, and step metadata. See the neverthrow comparison.

Do I need a database for durable workflows?

No. Workflows run in memory by default. Plug in Postgres, Mongo, or a file store when you need crash recovery or human-in-the-loop pauses.

import { run } from "awaitly"

Stop hand-rolling error unions.

Install awaitly, wrap one handler with run(), and let the compiler track what can fail.

npm install awaitly