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.
The Abstraction-Level Difference
Section titled “The Abstraction-Level Difference”Effect is closer to 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 closer to application-level orchestration. 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: a small model for async workflows, typed errors, and value-level dependencies — without a custom 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 │└─────────────────────────┘ └───────────────────────────┘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 } from 'awaitly';import { run } from 'awaitly/run';
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" | UnexpectedErrorimport { Effect, Data } from 'effect';
class NotFound extends Data.TaggedError('NotFound')<{}> {}
const fetchUser = (id: string) => id === '1' ? Effect.succeed({ id, name: 'Ada' }) : Effect.fail(new NotFound());
const program = Effect.gen(function* () { const user = yield* fetchUser(id); return user.name;});
const result = await Effect.runPromise(Effect.either(program));// result._tag === 'Right' ? result.right : result.leftawaitly stays in async/await. In workflow-style APIs (flow, 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 module-by-module.
Closest Equivalent to Effect.gen: flow()
Section titled “Closest Equivalent to Effect.gen: flow()”flow() gives you Effect-like sequential composition while staying in native async/await. The body is just async/await; each dep call is automatically a tracked step using its property name as the id, and errors are inferred from the deps’ return types.
See the dedicated guide: flow().
import { flow } from 'awaitly/flow';import { 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 flow({ getUser, getPosts }, async (d) => { const user = await d.getUser('1'); const posts = await d.getPosts(user.id); return { user, posts };});
// result.error: 'NOT_FOUND' | 'POSTS_FAILED' | UnexpectedErrorimport { Effect, Data } from 'effect';
class NotFound extends Data.TaggedError('NotFound')<{}> {}class PostsFailed extends Data.TaggedError('PostsFailed')<{}> {}
const getUser = (id: string) => id === '1' ? Effect.succeed({ id, name: 'Ada' }) : Effect.fail(new NotFound());
const getPosts = (userId: string) => Effect.succeed([{ id: 'p1', userId }]).pipe( Effect.mapError(() => new PostsFailed()) );
const program = Effect.gen(function* () { const user = yield* getUser('1'); const posts = yield* getPosts(user.id); return { user, posts };});
const result = await Effect.runPromise(Effect.either(program));Optional: per-call escape hatch (c)
Section titled “Optional: per-call escape hatch (c)”When you need a custom step id (e.g. calling the same dep twice) or a named parallel scope, flow() passes an optional second argument — the flow context — with c.key(id, fn), c.all(name, ops), and c.raw (the un-wrapped original deps). For richer control (per-call retry/timeout/cache, step.map, step.race, resume) drop down to run() / createWorkflow().
const result = await flow({ getUser, getPosts }, async (d, c) => { const user = await c.key('user:primary', () => c.raw.getUser('1'));
const { posts, profile } = await c.all('fetchProfileBundle', { posts: () => c.raw.getPosts(user.id), profile: () => c.raw.getUser(user.id), });
return { user, posts, profile };});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), 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/ratelimit | RateLimiter |
| Circuit breaker | awaitly/circuit-breaker | Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure |
| Saga / compensation | awaitly/saga | Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure |
| Durable execution | awaitly/durable | Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure |
| Human-in-the-loop | awaitly/hitl | Not a dedicated first-class module in Effect core; typically built from primitives or handled by external infrastructure |
| 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 |
Step helpers
Section titled “Step helpers”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) |
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 { run } from 'awaitly/run';import { 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' | UnexpectedErrorimport { createWorkflow } from 'awaitly/workflow';
// createWorkflow infers errors automatically — no ErrorsOf neededconst workflow = createWorkflow('workflow', { fetchUser, sendEmail });
const result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1')); await step('sendEmail', () => deps.sendEmail(user.email)); return user;});import { Effect, pipe } from 'effect';
const fetchUser = (id: string) => id === '1' ? Effect.succeed({ id, email: 'user@example.com' }) : Effect.fail('NOT_FOUND' as const);
const sendEmail = (to: string) => to.includes('@') ? Effect.succeed(undefined) : Effect.fail('EMAIL_FAILED' as const);
// 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', initialDelay: 100, } ); return data;});/*Retry timeline (exponential, initialDelay 100ms):Attempt 1: immediateRetry delay #1: 100ms → Attempt 2Retry delay #2: 200ms → Attempt 3*/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 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.)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', initialDelay: 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', initialDelay: 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('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 };});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 programGen = 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 };});
// Array form: step.all(name, () => allAsync([...]))import { 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 (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 } = Awaitly.partition(results);// values: [1, 3], errors: ['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, 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}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 has a smaller baseline; both grow depending on which modules you use)
- 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.