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', typedawaitly
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 awaitlyReturn 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.
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.
getOrder- ORDER_NOT_FOUND
getUser- USER_NOT_FOUND
charge- CHARGE_DECLINED
- INSUFFICIENT_FUNDS
step.retry(), step.withTimeout(), and idempotency keys use the same shape. The union still reflects your deps.
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.
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) };
}
}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;
}
}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}`
});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 library | try / catchJavaScript | neverthrowResult types | Effectfp ecosystem | |
|---|---|---|---|---|
| async / await syntax | ● | ● | ◐methods | ◐gens / pipe |
| errors typed from deps | ● | ○unknown | ◐manual unions | ● |
| retries / timeouts built in | ● | ○ | ○ | ● |
| pause / resume workflows | ● | ○ | ○ | ◐via fibers |
| learning curve | one new fn | none | low | runtime + DSL |
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