Skip to content

awaitly vs Effect

Effect is a comprehensive FP runtime and ecosystem for TypeScript. It targets full application architecture (concurrency, resource safety, dependency management, and a large stdlib). awaitly focuses on typed async workflows and orchestration, staying on native async/await (no Effect runtime or generator DSL).

  • Choose awaitly if you want async/await-native workflows with typed errors and orchestration features (sagas, durable execution, circuit breakers, HITL).
  • Choose Effect if you want a full runtime with fibers, Layers, Scope, Schedule, Stream, and a larger FP ecosystem.

The sections below go into detail.

Effect works at the infrastructure level. It’s a full effect runtime with a capability-tracking type system: fibers, Scope, Layers, Schedule algebra. The runtime manages effects (concurrency, interruption, resource lifecycles), and the type system encodes that architecture.

awaitly works at the application-orchestration level. It’s a minimal effect abstraction layered directly on JavaScript: async/await, Result types, and a step engine. No custom runtime. Architecture lives in values (deps, config, store), not in a capability graph.

These sit at different layers of the stack. awaitly is a deliberately minimal computation model for TypeScript apps: async workflows, typed errors, and value-level dependencies, with no custom runtime. Effect is the tool when you want that runtime and the full type-level encoding of capabilities.

Effect awaitly
┌─────────────────────────┐ ┌───────────────────────────┐
│ FP runtime & ecosystem │ │ async/await + Result<T,E>│
│ ────────────────────── │ │ ──────────────────────── │
│ • Fibers & concurrency │ │ • Native Promises │
│ • Layer-based DI │ │ • Workflow orchestration │
│ • Schedule combinators │ │ • Retry/timeout/limits │
│ • Scope & resources │ │ • Automatic error union │
│ • Comprehensive stdlib │ │ • Circuit breaker, saga │
│ │ │ • Durable execution │
│ │ │ • Human-in-the-loop │
└─────────────────────────┘ └───────────────────────────┘

First impressions: the same code you’d write today

Section titled “First impressions: the same code you’d write today”

If you can write async/await, you already know awaitly. There is no generator DSL or custom runtime to adopt. You write code that looks like the code you wrote yesterday, and you get typed errors and step-level observability by default, with retries available through the step engine when you need them.

import { ok, err, type AsyncResult, run } from 'awaitly';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
id === '1' ? ok({ id, name: 'Ada' }) : err('NOT_FOUND');
const result = await run(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser(id));
return user.name;
});
// result.ok ? result.value : result.error — error is "NOT_FOUND" | UnexpectedError

awaitly stays in async/await. In workflow-style APIs (run, createWorkflow), errors are inferred from your dependency object, no manual annotations, no Effect<A, E, R> to thread through. When you’re ready for more (retries, sagas, durable execution, dependency injection), you opt in as needed.

Closest Equivalent to Effect.gen: run() deps-first

Section titled “Closest Equivalent to Effect.gen: run() deps-first”

The deps-first form run(deps, fn) gives you Effect-like sequential composition while staying in native async/await. The body is async/await; each dep call on the bound steps object is automatically a tracked step using its property name as the id, and errors are inferred from the deps’ return types.

import { run, ok, err, type AsyncResult } from 'awaitly';
type User = { id: string; name: string };
type Post = { id: string; userId: string };
const getUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
id === '1' ? ok({ id, name: 'Ada' }) : err('NOT_FOUND');
const getPosts = async (userId: string): AsyncResult<Post[], 'POSTS_FAILED'> =>
ok([{ id: 'p1', userId }]);
const result = await run({ getUser, getPosts }, async (s) => {
const user = await s.getUser('1');
const posts = await s.getPosts(user.id);
return { user, posts };
});
// result.error: 'NOT_FOUND' | 'POSTS_FAILED' | UnexpectedError

More control: explicit steps and parallel scopes

Section titled “More control: explicit steps and parallel scopes”

When you need a custom step id (e.g. calling the same dep twice), parallel execution, or per-call retry/timeout/cache, use the explicit step API the callback also receives. step.all runs a named parallel scope; step.map / step.race and retry/timeout options are all available here. For dependency injection, testing overrides, and resume, graduate to createWorkflow().

const result = await run({ getUser, getPosts }, async (s, { step }) => {
const user = await step('user:primary', () => getUser('1'));
const { posts, profile } = await step.all('fetchProfileBundle', {
posts: () => getPosts(user.id),
profile: () => getUser(user.id),
});
return { user, posts, profile };
});

This table compares Effect core/stdlib with awaitly core plus optional awaitly modules (ratelimit, durable, saga, etc.). Effect can implement many of these patterns via primitives or ecosystem packages; awaitly provides some as ready-made modules.

Feature awaitly Effect
Learning curve Low (async/await mental model) Higher (runtime model + Effect.gen/Layers)
Bundle footprint Small; grows with modules used Larger baseline; grows with modules used
Result type Result<T, E> Effect<A, E, R>
Error typing Inferred from workflow deps + usage Tracked in E; generally inferred through composition (flatMap, gen, etc.)
Async model Native Promises Effect runtime with fibers
Dependency injection createWorkflow('name', deps), withDeps(workflow, overrides), and override per run via workflow.run(fn, { deps }) Layers (Context-based DI)
Retry / scheduling Config objects Schedule combinators
Concurrency step.all / step.map / step.race (inside workflows) Fibers
Interruption / cancellation Cooperative via AbortSignal Structured fiber interruption
Rate limiting awaitly (createRateLimiter) RateLimiter
Circuit breaker awaitly (createCircuitBreaker) Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure
Saga / compensation awaitly/durable (createSagaWorkflow) Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure
Durable execution awaitly/durable (durable) Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure
Human-in-the-loop awaitly (approvals/hooks) Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure
Resource management awaitly (step.withResource, scoped cleanup) Scope (runtime-integrated)
Observability onEvent workflow event stream → OTel at step boundaries Runtime-integrated spans/tracing APIs (OTel export requires SDK setup)
Tagged errors TaggedError with type + matching Data.TaggedEnum / _tag

awaitly’s step helpers are deliberately small. The core helpers cover the 80%. Every other concern lives as an option on step itself.

awaitly Effect equivalent
step('id', () => fetchUser(id)) yield* fetchUser(id) (unwrap + chain)
step('id', () => fn(value)) yield* fn(value) (chain)
if (!result.ok) ... at workflow boundary Effect.match(program, { onSuccess, onFailure })
step.try('id', fn, { onError }) Effect.tryPromise({ try, catch })
step.all('name', { a, b }) Effect.all({ a, b })
step.map('id', items, mapper) Effect.forEach(items, mapper)
step.race('name', op) Effect.race(op)
step.sleep('id', ms) Effect.sleep(ms)
// step.all(id, shape, opts?): named results, step tracking
const { user, posts } = await step.all('fetchAll', {
user: () => fetchUser('1'),
posts: () => fetchPosts('1'),
});
// step.map(id, items, mapper, opts?): parallel, step tracking
const users = await step.map('fetchUsers', ['1', '2', '3'], (id) => fetchUser(id));
import { ok, err, type Result } from 'awaitly';
const divide = (a: number, b: number): Result<number, 'DIVIDE_BY_ZERO'> =>
b === 0 ? err('DIVIDE_BY_ZERO') : ok(a / b);
const result = divide(10, 2);
if (result.ok) {
console.log(result.value);
}
/*
Output:
5
*/
awaitly Result:
┌─── Success value type
│ ┌─── Error type
▼ ▼
Result<number, 'DIVIDE_BY_ZERO'>
Effect:
┌─── Success value
│ ┌─── Error type
│ │ ┌─── Requirements (dependencies)
▼ ▼ ▼
Effect<number, 'DIVIDE_BY_ZERO', never>
import { run, type ErrorsOf } from 'awaitly';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };
const sendEmail = async (to: string): AsyncResult<void, 'EMAIL_FAILED'> => { /* ... */ };
const deps = { fetchUser, sendEmail };
type Errors = ErrorsOf<typeof deps>;
const result = await run<User, Errors>(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('1'));
await step('sendEmail', () => sendEmail(user.email));
return user;
});
// TypeScript knows: result.error is 'NOT_FOUND' | 'EMAIL_FAILED' | UnexpectedError
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'));
return user;
});
if (!result.ok) {
switch (result.error) {
case 'NOT_FOUND':
console.log('User not found');
break;
case 'EMAIL_FAILED':
console.log('Email failed');
break;
}
}

awaitly uses retry policies while Effect uses Schedule combinators. Both achieve similar outcomes with different approaches.

const result = await workflow.run(async ({ step, deps }) => {
const data = await step.retry(
'fetchData',
() => deps.fetchData(),
{
attempts: 3,
backoff: 'exponential',
initialDelay: 100,
}
);
return data;
});
/*
Retry timeline (exponential, initialDelay 100ms):
Attempt 1: immediate
Retry delay #1: 100ms → Attempt 2
Retry delay #2: 200ms → Attempt 3
*/
awaitly Exponential Retry Delays (initialDelay: 100)
────────────────────────────────────────────
Retry delay #1: 100ms ████
Retry delay #2: 200ms ████████
Retry delay #3: 400ms ████████████████
Retry delay #4: 800ms ████████████████████████████████
(Attempt 1 runs immediately; delays apply *before* each retry.)
const result = await workflow.run(async ({ step, deps }) => {
const data = await step.retry(
'fetchData',
() => step.withTimeout(
'fetchData',
() => deps.fetchData(),
{ ms: 5000 }
),
{ attempts: 3, backoff: 'exponential', initialDelay: 100 }
);
return data;
});
const result = await step.retry(
'fetchData',
() => deps.fetchData(),
{
attempts: 5,
backoff: 'exponential',
initialDelay: 100,
jitter: true, // Adds random variation
}
);
import { createRateLimiter } from 'awaitly';
const limiter = createRateLimiter('api', {
maxPerSecond: 2,
burstCapacity: 5,
});
const result = await workflow.run(async ({ step, deps }) => {
// Rate limit *when the step starts*
const data = await limiter.execute(() => step('fetchData', () => deps.fetchData()));
return data;
});
import { createWorkflow } from 'awaitly';
const workflow = createWorkflow('checkout', {
fetchUser,
validateOrder,
chargeCard,
sendConfirmation,
});
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser(userId));
const order = await step('validateOrder', () => deps.validateOrder(orderData));
const receipt = await step('chargeCard', () => deps.chargeCard(order.total));
await step('sendConfirmation', () => deps.sendConfirmation(user.email, receipt));
return { user, order, receipt };
});
// Effect-style: step.all, named results, step tracking
const result = await workflow.run(async ({ step, deps }) => {
const { user, posts, comments } = await step.all('fetchAll', {
user: () => deps.fetchUser('1'),
posts: () => deps.fetchPosts('1'),
comments: () => deps.fetchComments('1'),
});
return { user, posts, comments };
});
// Array form: step.all(name, () => allAsync([...]))
// Option 1: In-memory (simple)
const workflow = createWorkflow('workflow', deps, {
cache: new Map(),
resumeState: savedState, // Resume from previous run
});
// Option 2: Store (awaitly-mongo or awaitly-postgres) — runWithState + save/loadResumeState
import { mongo } from 'awaitly-mongo';
// or: import { postgres } from 'awaitly-postgres';
const store = mongo(process.env.MONGODB_URI!);
const { result, resumeState } = await workflow.runWithState(async ({ step, deps }) => {
const user = await step('fetchUser', () => deps.fetchUser('1'), { key: 'user:1' });
return user;
});
await store.save('wf-1', resumeState);
// Restore: loadResumeState then run with resumeState
const loaded = await store.loadResumeState('wf-1');
if (loaded) {
await workflow.run(async ({ step, deps }) => { /* same fn */ }, { resumeState: loaded });
}

Both libraries provide ways to combine multiple Results/Effects.

import { allAsync } from 'awaitly';
// In a workflow: step.all (named) or step.map (array)
const { u1, u2, u3 } = await step.all('fetchUsers', {
u1: () => fetchUser('1'),
u2: () => fetchUser('2'),
u3: () => fetchUser('3'),
});
// Standalone: allAsync
const users = await allAsync([
fetchUser('1'),
fetchUser('2'),
fetchUser('3'),
]);
// { ok: true, value: [user1, user2, user3] } or first error
import { ok, err, allSettled, partition } from 'awaitly';
const results = [ok(1), err('A'), ok(3), err('B')];
const settled = allSettled(results);
/*
Output (any Err present → Err with all errors collected):
{
ok: false,
error: [{ error: 'A' }, { error: 'B' }]
}
If every Result is Ok, the success values are returned as an array:
{ ok: true, value: [1, 3] }
To keep both successes and failures, use `partition` instead.
*/
// Keep both successes and failures with partition:
const { values, errors } = partition(results);
// values: [1, 3], errors: ['A', 'B']

Both libraries provide scoped resource management with automatic cleanup in LIFO order. Effect’s Scope is integrated into the runtime; awaitly’s withScope is explicit and library-level.

import { withScope, createResource } from 'awaitly';
const dbResource = await createResource(
() => new DatabaseClient().connect(), // acquire
(client) => client.disconnect() // release
);
const result = await withScope(async (scope) => {
const db = scope.add(dbResource); // scope.add returns the value directly
const cache = scope.add(
await createResource(
() => createCacheClient(db),
(c) => c.disconnect()
)
);
const users = await db.query('SELECT * FROM users');
return ok(users);
});
// cleanup runs automatically: cache closed first, then db (LIFO)
// Cleanup errors are collected, not swallowed
if (!result.ok && isResourceCleanupError(result.error)) {
console.log(result.error.errors); // all cleanup failures
console.log(result.error.originalResult); // the actual result before cleanup failed
}

Both libraries support streaming data processing.

import { createWorkflow } from 'awaitly';
import { createMemoryStreamStore, getStreamReader, toAsyncIterable, collect } from 'awaitly/durable';
const streamStore = createMemoryStreamStore();
const workflow = createWorkflow('enrich', deps, { streamStore });
const result = await workflow.run(async ({ step }) => {
const writer = step.getWritable<User>({ namespace: 'users' });
for (const user of users) {
const enriched = await step('enrich', () => enrichUser(user));
await writer.write(enriched);
}
await writer.close();
});
// Consume the stream externally (outside the workflow)
const reader = getStreamReader<User>({
store: streamStore,
workflowId: 'enrich',
namespace: 'users',
});
// Collect all items
const allUsers = await collect(reader);
// Or process one at a time
for await (const user of toAsyncIterable(reader)) {
await processUser(user);
}

Effect leans on pipe/flow to thread a value through combinators. awaitly deliberately has no pipe/flow: you compose Results with the plain combinator functions (map, andThen, mapError) or, more often, stay in async/await.

import { ok, err, map, andThen, mapError, type Result } from 'awaitly';
// Compose with the Result combinators — plain function calls, no pipe()
const doubled = map(ok(5), (n) => n * 2);
const checked = andThen(doubled, (n) => (n > 5 ? ok(n) : err('TOO_SMALL')));
const result = mapError(checked, (e) => ({ code: e }));
// Wrap the sequence in a reusable function
const processNumber = (input: Result<number, never>) =>
mapError(
andThen(
map(input, (n: number) => n * 2),
(n) => (n > 5 ? ok(n) : err('TOO_SMALL')),
),
(e) => ({ code: e }),
);
const out = processNumber(ok(5));

awaitly dispatches on .type; Effect uses _tag.

import { TaggedError } from 'awaitly';
class NotFoundError extends TaggedError("NotFoundError")<{
id: string;
resource: string;
}> {}
class ValidationError extends TaggedError("ValidationError", {
message: (p: { field: string }) => `Invalid ${p.field}`
}) {}
// Instances are real Error objects with instanceof support
const err = new NotFoundError({ id: '123', resource: 'User' });
err.type; // "NotFoundError"
err instanceof Error; // true
// Exhaustive matching: compiler errors if you miss a case
type AppError = NotFoundError | ValidationError;
const msg = TaggedError.match(error as AppError, {
NotFoundError: (e) => `Missing ${e.resource}: ${e.id}`,
ValidationError: (e) => e.message,
});

awaitly includes a circuit breaker. Effect doesn’t ship one as a core feature; you’d typically build it from primitives (Ref, Schedule) or use an ecosystem package.

import { createCircuitBreaker } from 'awaitly';
const apiBreaker = createCircuitBreaker('external-api', {
failureThreshold: 5, // open after 5 failures
resetTimeout: 30_000, // try again after 30s
halfOpenMax: 3, // allow 3 test requests in half-open
});
const result = await workflow.run(async ({ step, deps }) => {
const data = await apiBreaker.executeResult(
() => step('fetchData', () => deps.fetchData())
);
return data;
});
// Check state programmatically
apiBreaker.getState(); // "CLOSED" | "OPEN" | "HALF_OPEN"
apiBreaker.getStats(); // { failures, successes, state, ... }
Circuit Breaker States
──────────────────────────────────────────────────────
CLOSED ──(failures hit threshold)──► OPEN
▲ │
│ (resetTimeout expires)
│ ▼
└────(test requests pass)──── HALF_OPEN

When a multi-step operation fails partway through, you need to undo the steps that already succeeded. awaitly ships a saga pattern that runs compensations in reverse order automatically. Effect doesn’t ship saga/compensation as a core feature; you’d compose it manually with acquireRelease or catchAll.

import { createSagaWorkflow } from 'awaitly/durable';
const checkout = createSagaWorkflow('checkout', {
reserveInventory, releaseInventory,
chargeCard, refundPayment,
sendEmail,
});
const result = await checkout.run(async ({ step, deps }) => {
const reservation = await step(
'reserve',
() => deps.reserveInventory(items),
{ compensate: (res) => deps.releaseInventory(res.id) }
);
const payment = await step(
'charge',
() => deps.chargeCard(amount),
{ compensate: (p) => deps.refundPayment(p.txId) }
);
await step('notify', () => deps.sendEmail(userId));
return { reservation, payment };
});
// If chargeCard fails:
// 1. releaseInventory runs automatically (reverse order)
// 2. result.ok === false
// If a compensation itself fails, errors are collected:
if (!result.ok && isSagaCompensationError(result.error)) {
result.error.originalError; // what triggered the rollback
result.error.compensationErrors; // which cleanups failed
}

awaitly can checkpoint workflow state after each keyed step, so if the process crashes, the workflow resumes from the last completed step instead of re-running everything.

import { durable } from 'awaitly/durable';
const result = await durable.run(
{ fetchUser, createOrder, sendEmail },
async ({ step, deps }) => {
// Each keyed step is checkpointed; if the process crashes
// after 'createOrder', it won't re-run 'fetchUser' on resume
const user = await step('fetchUser', () => deps.fetchUser('123'), { key: 'user' });
const order = await step('createOrder', () => deps.createOrder(user), { key: 'order' });
await step('sendEmail', () => deps.sendEmail(order), { key: 'email' });
return order;
},
{
id: 'checkout-123',
store, // awaitly-postgres or awaitly-mongo
version: 1, // bump when workflow logic changes
}
);
// Query pending workflows
const pending = await durable.listPending(store);
// Clean up old state
await durable.deleteState(store, 'checkout-123');

awaitly can pause a workflow mid-execution to wait for a human approval, then resume from where it left off.

import { createHITLOrchestrator, createMemoryApprovalStore, createMemoryWorkflowStateStore } from 'awaitly/durable';
const orchestrator = createHITLOrchestrator({
approvalStore: createMemoryApprovalStore(),
workflowStateStore: createMemoryWorkflowStateStore(),
notificationChannel: slackNotifier, // optional: notify reviewers
});
// Start workflow: it pauses when it hits an approval gate
const execution = await orchestrator.execute(
'large-refund',
workflowFactory,
async ({ step, deps, args }) => {
const refund = await step('calculate', () => deps.calculateRefund(args.orderId));
// Workflow pauses here until approved
await step('approve', () => deps.requireApproval(refund), { key: `refund:${refund.id}` });
await step('process', () => deps.processRefund(refund));
return refund;
},
{ orderId: '456' }
);
// `runId` only exists on the paused/resumed arms, so narrow before using it.
if (execution.status === 'paused') {
console.log('Waiting for:', execution.pendingApprovals);
// Later: manager approves via API/webhook
await orchestrator.grantApproval('refund:789', { approvedBy: 'manager@co.com' });
const resumed = await orchestrator.resume(execution.runId, workflowFactory, fn);
}
import { init } from 'autotel';
import { createWorkflow } from 'awaitly';
init({ service: 'checkout-api' });
const workflow = createWorkflow('checkout', deps);
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('fetch-user', () => deps.fetchUser('1'));
return step('charge', () => deps.charge(user));
});
// awaitly creates run, step, retry, scope, and compensation spans.

If you rely heavily on scoped resources and structured concurrency: Effect remains the better tool. Fibers, Scope, and interruption are built in; awaitly doesn’t model that level of lifecycle.

If your team finds the runtime and abstraction surface too heavy. awaitly offers a simpler mental model: same async/await, typed errors in the return type, deps passed as values. No generators, no Layer graph.

Both get you typed errors and composition. The trade-off is runtime power and type-level architecture (Effect) against application-level simplicity (awaitly).

  • Your team knows async/await and you want typed errors without learning a new paradigm
  • You need workflow features like sagas, durable execution, circuit breakers, or human-in-the-loop
  • You want automatic error type inference from your dependencies
  • Bundle footprint matters (awaitly has a smaller baseline; both grow depending on which modules you use)
  • You want to adopt incrementally: start with ok/err in one function, add workflows later
  • You want a full functional programming system with fibers and structured concurrency
  • You need the Layer system for complex dependency injection graphs
  • You want composable Schedule types for advanced retry/repeat logic
  • Your team is comfortable with generators and functional composition
  • You want Effect’s extensive standard library (Schema, Stream, STM, etc.)
  • Fibers: Lightweight threads with structured concurrency, interruption, and forking. awaitly uses native Promises.
  • Layer system: Compile-time verified dependency graphs. awaitly’s DI is simpler: deps are passed to createWorkflow.
  • Schedule combinators: Composable scheduling algebra. awaitly uses config objects ({ attempts: 3, backoff: 'exponential' }).
  • STM: Software transactional memory. awaitly doesn’t have an equivalent.
  • Schema: Runtime validation library. awaitly doesn’t include one (use Zod, Valibot, etc.).

What awaitly provides as first-class modules

Section titled “What awaitly provides as first-class modules”
  • Circuit breaker: CLOSED/OPEN/HALF_OPEN states, presets.
  • Saga / compensation: Reverse-order rollback on failure.
  • Durable execution: Checkpoint and resume across restarts.
  • Human-in-the-loop: Pause and resume via approval store.
  • Automatic error inference: Inferred union from deps; no manual wiring.

Effect encodes architecture in types; awaitly keeps it in values.