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).
The Abstraction-Level Difference
Section titled “The Abstraction-Level Difference”Effect is infrastructure-level. It’s a full effect runtime with a capability-tracking type system: fibers, Scope, Layers, Schedule algebra. You get a runtime that manages effects — concurrency, interruption, resource lifecycles — and the type system encodes that architecture.
awaitly is application-level. It’s a minimal effect abstraction layered directly on top of JavaScript: async/await, Result types, and a step engine. No custom runtime. Architecture lives in values (deps, config, store), not in a capability graph.
That’s not “Effect minus runtime” — it’s a different layer of the stack. awaitly is a deliberately minimal computation model for TypeScript applications: one type for async + errors + environment, without a runtime. Effect is the tool when you want the runtime and the full type-level encoding of capabilities.
Philosophy Comparison
Section titled “Philosophy Comparison”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 │└─────────────────────────┘ └───────────────────────────┘Quick Comparison
Section titled “Quick Comparison”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); override per run via workflow.run(fn, { deps }) | Layers (Context-based DI) |
| Retry / scheduling | Config objects | Schedule combinators |
| Concurrency | Promise.all / step.all | Fibers |
| Rate limiting | awaitly/ratelimit | RateLimiter |
| Circuit breaker | awaitly/circuit-breaker | Not shipped as a core feature; built from primitives or ecosystem packages |
| Saga / compensation | awaitly/saga | Not shipped as a core feature; built from primitives or ecosystem packages |
| Durable execution | awaitly/durable | Not shipped as a core feature; built from primitives or ecosystem packages |
| Human-in-the-loop | awaitly/hitl | Not shipped as a core feature; built from primitives or ecosystem packages |
| Resource management | awaitly/resource (scoped cleanup) | Scope (runtime-integrated) |
| Observability | Event-to-OTel adapter at step boundaries | Runtime-integrated spans/tracing APIs (OTel export requires SDK setup) |
| Tagged errors | TaggedError with _tag + matching | Data.TaggedEnum / _tag |
Effect-style step helpers
Section titled “Effect-style step helpers”awaitly’s step helpers are aligned with Effect’s API so the surface feels familiar to Effect users: as close as we can get while still using async/await and not generators. They run through the full step engine (events, retry, timeout, and in createWorkflow: cache and onAfterStep).
Unwrap (run)
Section titled “Unwrap (run)”// step.run(id, result, opts?): unwraps AsyncResult, exits on errorconst user = await step.run('fetchUser', () => fetchUser('1'), { key: 'user:1' });const user = yield* fetchUser('1');Chain (flatMap / andThen)
Section titled “Chain (flatMap / andThen)”// step.andThen(id, value, fn, opts?)const enriched = await step.andThen('enrich', user, (u) => enrichUser(u));const enriched = yield* enrichUser(user);Pattern match (match)
Section titled “Pattern match (match)”// step.match(id, result, { ok, err }, opts?): step-trackedconst msg = await step.match('handleUser', userResult, { ok: (user) => `Hello ${user.name}`, err: () => 'Failed',});const msg = yield* Effect.match(userResult, { onSuccess: (user) => `Hello ${user.name}`, onFailure: () => 'Failed',});Parallel named (all)
Section titled “Parallel named (all)”// step.all(id, shape, opts?): named results, step trackingconst { user, posts } = await step.all('fetchAll', { user: () => fetchUser('1'), posts: () => fetchPosts('1'),});const { user, posts } = yield* Effect.all({ user: fetchUser('1'), posts: fetchPosts('1'),});Map over array (map)
Section titled “Map over array (map)”// step.map(id, items, mapper, opts?): parallel, step trackingconst users = await step.map('fetchUsers', ['1', '2', '3'], (id) => fetchUser(id));const users = yield* Effect.all( ['1', '2', '3'].map((id) => fetchUser(id)));Result Types
Section titled “Result Types”Creating Results
Section titled “Creating Results”import { Awaitly, type Result } from 'awaitly';
const divide = (a: number, b: number): Result<number, 'DIVIDE_BY_ZERO'> => b === 0 ? Awaitly.err('DIVIDE_BY_ZERO') : Awaitly.ok(a / b);
const result = divide(10, 2);if (result.ok) { console.log(result.value);}/*Output:5*/import { Effect } from 'effect';
const divide = (a: number, b: number) => b === 0 ? Effect.fail('DIVIDE_BY_ZERO' as const) : Effect.succeed(a / b);
// Must run through Effect runtimeconst program = divide(10, 2);Effect.runSync(program); // 5Type Structure
Section titled “Type Structure”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>Error Handling
Section titled “Error Handling”Error Type Inference
Section titled “Error Type Inference”import { createWorkflow } from 'awaitly/workflow';
const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { /* ... */ };const sendEmail = async (to: string): AsyncResult<void, 'EMAIL_FAILED'> => { /* ... */ };
// Error types automatically inferred from dependenciesconst workflow = createWorkflow('workflow', { fetchUser, sendEmail });
const result = await workflow.run(async ({ step, deps }) => { const user = await step.run('fetchUser', () => deps.fetchUser('1')); await step('sendEmail', () => deps.sendEmail(user.email)); return user;});
// TypeScript knows: result.error is 'NOT_FOUND' | 'EMAIL_FAILED' | UnexpectedError// step.run unwraps AsyncResult; use step('id', fn) or step.run with a getter when cachingimport { Effect, pipe } from 'effect';
const fetchUser = (id: string) => Effect.fail('NOT_FOUND' as const).pipe( Effect.map(() => ({ id, email: 'user@example.com' })) );
const sendEmail = (to: string) => Effect.fail('EMAIL_FAILED' as const).pipe( Effect.map(() => undefined) );
// Must compose effects explicitlyconst program = pipe( fetchUser('1'), Effect.flatMap(user => pipe( sendEmail(user.email), Effect.map(() => user) ) ));
// TypeScript knows: E = 'NOT_FOUND' | 'EMAIL_FAILED'Catching Errors
Section titled “Catching Errors”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; }}import { Effect } from 'effect';
const handled = program.pipe( Effect.catchTag('NOT_FOUND', () => Effect.succeed({ fallback: true }) ), Effect.catchTag('EMAIL_FAILED', () => Effect.succeed({ emailSkipped: true }) ));Retry and Scheduling
Section titled “Retry and Scheduling”awaitly uses retry policies while Effect uses Schedule combinators. Both achieve similar outcomes with different approaches.
Basic Retry
Section titled “Basic Retry”const result = await workflow.run(async ({ step, deps }) => { const data = await step.retry( 'fetchData', () => deps.fetchData(), { attempts: 3, backoff: 'exponential', delayMs: 100, } ); return data;});/*Retry timeline:Attempt 1: immediateAttempt 2: after 100msAttempt 3: after 200ms*/import { Effect, Schedule } from 'effect';
const policy = Schedule.exponential('100 millis').pipe( Schedule.compose(Schedule.recurs(3)));
const program = fetchData.pipe( Effect.retry(policy));
Effect.runPromise(program);Backoff Visualization
Section titled “Backoff Visualization”awaitly Exponential Backoff (delayMs: 100)────────────────────────────────────────────#1: 100ms ████#2: 200ms ████████#3: 400ms ████████████████#4: 800ms ████████████████████████████████With Timeout
Section titled “With Timeout”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', delayMs: 100 } ); return data;});import { Effect, Schedule, Duration } from 'effect';
const policy = Schedule.exponential('100 millis').pipe( Schedule.compose(Schedule.recurs(3)));
const program = fetchData.pipe( Effect.timeout(Duration.seconds(5)), Effect.retry(policy));With Jitter
Section titled “With Jitter”const result = await step.retry( 'fetchData', () => deps.fetchData(), { attempts: 5, backoff: 'exponential', delayMs: 100, jitter: true, // Adds random variation });const policy = Schedule.exponential('100 millis').pipe( Schedule.jittered, Schedule.compose(Schedule.recurs(5)));Rate Limiting
Section titled “Rate Limiting”import { createRateLimiter } from 'awaitly/ratelimit';
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 { Effect, RateLimiter } from 'effect';
// fetchData: Effect<Data, Error, never>const program = Effect.scoped( Effect.gen(function* (_) { const rateLimit = yield* _( RateLimiter.make({ limit: 2, interval: '1 seconds' }) ); return yield* _(rateLimit(fetchData)); }));Workflow Orchestration
Section titled “Workflow Orchestration”Multi-step Workflows
Section titled “Multi-step Workflows”import { createWorkflow } from 'awaitly/workflow';
const workflow = createWorkflow('workflow', { 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 };});import { Effect, pipe } from 'effect';
const program = pipe( fetchUser(userId), Effect.flatMap(user => pipe( validateOrder(orderData), Effect.flatMap(order => pipe( chargeCard(order.total), Effect.flatMap(receipt => pipe( sendConfirmation(user.email, receipt), Effect.map(() => ({ user, order, receipt })) ) ) ) ) ) ));
// Or with Effect.gen (generator syntax)const program = Effect.gen(function* () { const user = yield* fetchUser(userId); const order = yield* validateOrder(orderData); const receipt = yield* chargeCard(order.total); yield* sendConfirmation(user.email, receipt); return { user, order, receipt };});Parallel Operations
Section titled “Parallel Operations”// Effect-style: step.all, named results, step trackingconst 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 };});
// Or step('id', () => allAsync([...])) for array resultsimport { Effect } from 'effect';
const program = Effect.all({ user: fetchUser('1'), posts: fetchPosts('1'), comments: fetchComments('1'),});Caching and Persistence
Section titled “Caching and Persistence”// 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/loadResumeStateimport { 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 resumeStateconst loaded = await store.loadResumeState('wf-1');if (loaded) { await workflow.run(async ({ step, deps }) => { /* same fn */ }, { resumeState: loaded });}import { Effect, Cache, Duration } from 'effect';
const cache = Cache.make({ capacity: 100, timeToLive: Duration.minutes(5), lookup: (key: string) => fetchUser(key),});
const program = Effect.gen(function* () { const c = yield* cache; return yield* c.get('1');});Combining Results
Section titled “Combining Results”Both libraries provide ways to combine multiple Results/Effects.
All (Fail-Fast)
Section titled “All (Fail-Fast)”import { Awaitly } 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: allAsyncconst users = await Awaitly.allAsync([ fetchUser('1'), fetchUser('2'), fetchUser('3'),]);// { ok: true, value: [user1, user2, user3] } or first errorimport { Effect } from 'effect';
const program = Effect.all([ fetchUser('1'), fetchUser('2'), fetchUser('3'),], { concurrency: 'unbounded' });const result = await Effect.runPromise(program);AllSettled (Collect All Errors)
Section titled “AllSettled (Collect All Errors)”import { Awaitly } from 'awaitly';
const results = [Awaitly.ok(1), Awaitly.err('A'), Awaitly.ok(3), Awaitly.err('B')];const settled = Awaitly.allSettled(results);/*Output:{ ok: false, error: ['A', 'B'], successes: [1, 3], failures: ['A', 'B']}*/import { Effect } from 'effect';
// Use Effect.allSettled or partitionconst program = Effect.forEach( [fetchUser('1'), fetchUser('2')], (effect) => Effect.either(effect));
// Returns array of Either<E, A>Resource Management
Section titled “Resource Management”Both libraries provide scoped resource management with automatic cleanup in LIFO order. Effect’s Scope is deeply integrated with the runtime; awaitly’s withScope is explicit and library-level.
import { withScope, createResource } from 'awaitly/resource';
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 swallowedif (!result.ok && isResourceCleanupError(result.error)) { console.log(result.error.errors); // all cleanup failures console.log(result.error.originalResult); // the actual result before cleanup failed}import { Effect, Scope } from 'effect';
const program = Effect.scoped( Effect.gen(function* () { const conn = yield* openConnection; const data = yield* query(conn, 'SELECT * FROM users'); return data; }) // conn automatically closed when scope exits);Streaming
Section titled “Streaming”Both libraries support streaming data processing.
import { createWorkflow } from 'awaitly/workflow';import { createMemoryStreamStore, getStreamReader, toAsyncIterable, collect } from 'awaitly/streaming';
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 itemsconst allUsers = await collect(reader);
// Or process one at a timefor await (const user of toAsyncIterable(reader)) { await processUser(user);}import { Stream, Effect } from 'effect';
// Effect has a full Stream moduleconst stream = Stream.fromIterable(fetchPaginatedUsers()).pipe( Stream.mapEffect((user) => enrichUser(user)), Stream.catchAll((e) => { console.error('Stream error:', e); return Stream.empty; }));
// Run the streamconst result = await Effect.runPromise( Stream.runCollect(stream));Functional Utilities
Section titled “Functional Utilities”Both libraries provide functional composition utilities.
import { Awaitly } from 'awaitly';
// pipe: Apply functions left-to-right to a valueconst result = Awaitly.pipe( Awaitly.ok(5), Awaitly.R.map(n => n * 2), Awaitly.R.flatMap(n => n > 5 ? Awaitly.ok(n) : Awaitly.err('TOO_SMALL')), Awaitly.R.mapError(e => ({ code: e })));
// flow: Create a new function from composed functionsconst processNumber = Awaitly.flow( Awaitly.R.map((n: number) => n * 2), Awaitly.R.flatMap(n => n > 5 ? Awaitly.ok(n) : Awaitly.err('TOO_SMALL')), Awaitly.R.mapError(e => ({ code: e })));
const result = processNumber(Awaitly.ok(5));import { pipe, flow, Effect } from 'effect';
// pipe: Apply functions left-to-right to a valueconst result = pipe( Effect.succeed(5), Effect.map(n => n * 2), Effect.flatMap(n => n > 5 ? Effect.succeed(n) : Effect.fail('TOO_SMALL')), Effect.mapError(e => ({ code: e })));
// flow: Create a new function from composed functionsconst processNumber = flow( Effect.map((n: number) => n * 2), Effect.flatMap(n => n > 5 ? Effect.succeed(n) : Effect.fail('TOO_SMALL')), Effect.mapError(e => ({ code: e })));
const result = processNumber(Effect.succeed(5));Tagged Errors
Section titled “Tagged Errors”Both use a _tag discriminant for pattern matching on error types.
import { TaggedError } from 'awaitly/tagged-error';
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 supportconst err = new NotFoundError({ id: '123', resource: 'User' });err._tag; // "NotFoundError"err instanceof Error; // true
// Exhaustive matching: compiler errors if you miss a casetype AppError = NotFoundError | ValidationError;const msg = TaggedError.match(error as AppError, { NotFoundError: (e) => `Missing ${e.resource}: ${e.id}`, ValidationError: (e) => e.message,});import { Data, Effect, Match } from 'effect';
class NotFoundError extends Data.TaggedError("NotFoundError")<{ id: string; resource: string;}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{ field: string;}> {}
// Pattern matching via catchTag in pipeconst handled = program.pipe( Effect.catchTag("NotFoundError", (e) => Effect.succeed(`Missing ${e.resource}: ${e.id}`) ), Effect.catchTag("ValidationError", (e) => Effect.succeed(`Invalid ${e.field}`) ));Circuit Breaker
Section titled “Circuit Breaker”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/circuit-breaker';
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 programmaticallyapiBreaker.getState(); // "CLOSED" | "OPEN" | "HALF_OPEN"apiBreaker.getStats(); // { failures, successes, state, ... }// No circuit breaker in core; typically built with Ref + Schedule:import { Effect, Ref, Schedule } from 'effect';
// You'd need to build state tracking, threshold logic,// half-open probing, and timeout reset manually using// Effect primitives.Circuit Breaker States────────────────────────────────────────────────────── CLOSED ──(failures hit threshold)──► OPEN ▲ │ │ (resetTimeout expires) │ ▼ └────(test requests pass)──── HALF_OPENSaga / Compensation
Section titled “Saga / Compensation”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/saga';
const checkout = createSagaWorkflow('checkout', { reserveInventory, chargeCard, sendEmail,});
const result = await checkout(async ({ saga }) => { const reservation = await saga.step( 'reserve', () => reserveInventory(items), { compensate: (res) => releaseInventory(res.id) } );
const payment = await saga.step( 'charge', () => chargeCard(amount), { compensate: (p) => refundPayment(p.txId) } );
await saga.step('notify', () => 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}import { Effect } from 'effect';
// No saga in core; compose manually with acquireRelease or catchAll:const program = Effect.gen(function* () { const reservation = yield* reserveInventory(items);
const payment = yield* chargeCard(amount).pipe( Effect.catchAll((e) => releaseInventory(reservation.id).pipe( Effect.flatMap(() => Effect.fail(e)) ) ) );
yield* sendEmail(userId).pipe( Effect.catchAll((e) => refundPayment(payment.txId).pipe( Effect.flatMap(() => releaseInventory(reservation.id)), Effect.flatMap(() => Effect.fail(e)) ) ) );
return { reservation, payment };});Durable Execution
Section titled “Durable Execution”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 workflowsconst pending = await durable.listPending(store);// Clean up old stateawait durable.deleteState(store, 'checkout-123');// Effect doesn't ship durable execution as a core feature; you'd typically// pair it with an external orchestrator (Temporal, Inngest, etc.) or// build persistence around your programs.Human-in-the-Loop
Section titled “Human-in-the-Loop”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/hitl';
const orchestrator = createHITLOrchestrator({ approvalStore: createMemoryApprovalStore(), workflowStateStore: createMemoryWorkflowStateStore(), notificationChannel: slackNotifier, // optional: notify reviewers});
// Start workflow: it pauses when it hits an approval gateconst 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' });
if (execution.status === 'paused') { console.log('Waiting for:', execution.pendingApprovals);}
// Later: manager approves via API/webhookawait orchestrator.grantApproval('refund:789', { approvedBy: 'manager@co.com' });const resumed = await orchestrator.resume(execution.runId, workflowFactory, fn);// Effect doesn't ship human-in-the-loop as a core feature; you'd typically// build it with Effect + an external queue (SQS, Redis, database polling)// and manual state persistence.Observability
Section titled “Observability”import { createAutotelAdapter } from 'awaitly/otel';import { createWorkflow } from 'awaitly/workflow';
const otel = createAutotelAdapter({ serviceName: 'checkout', createStepSpans: true, recordMetrics: true,});
const workflow = createWorkflow('checkout', deps, { onEvent: otel.handleEvent, // plug into the event system});
// After execution, inspect metricsconst metrics = otel.getMetrics();metrics.stepDurations; // [{ name, durationMs, success }]metrics.retryCount;metrics.errorCount;metrics.cacheHits;import { Effect } from 'effect';
// Effect has built-in tracing via spansconst program = Effect.gen(function* () { const user = yield* fetchUser('1'); return user;}).pipe( Effect.withSpan('fetchUser'));
// Requires OpenTelemetry SDK setup for exportWhen to Choose Each
Section titled “When to Choose Each”The migration question
Section titled “The migration question”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.
Spell it out: both get you typed errors and composition; the trade-off is runtime power and type-level architecture (Effect) vs. application-level simplicity (awaitly).
Choose awaitly when:
Section titled “Choose awaitly when:”- 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 stays small; Effect is larger and scales with modules)
- You want to adopt incrementally: start with
ok/errin one function, add workflows later
Choose Effect when:
Section titled “Choose Effect when:”- 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.)
What Effect has that awaitly doesn’t
Section titled “What Effect has that awaitly doesn’t”- 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.
Bottom line: Effect encodes architecture in types; awaitly keeps architecture in values.