Migration Guide
This guide helps you migrate existing code to awaitly. Whether you’re coming from try/catch, neverthrow, or rolling your own Result types, we’ve got you covered.
From try/catch
Section titled “From try/catch”Basic pattern
Section titled “Basic pattern”async function processOrder(orderId: string): Promise<Order> { try { const order = await db.orders.findById(orderId); if (!order) { throw new Error('Order not found'); }
const payment = await paymentService.charge(order.total); if (!payment.success) { throw new Error('Payment failed'); }
await emailService.sendConfirmation(order.email);
return order; } catch (error) { console.error('Order processing failed:', error); throw error; }}import { ok, err, type AsyncResult } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';
type OrderError = 'NOT_FOUND' | 'PAYMENT_FAILED' | 'EMAIL_FAILED';
// Define Result-returning functionsasync function findOrder(id: string): AsyncResult<Order, 'NOT_FOUND'> { const order = await db.orders.findById(id); return order ? ok(order) : err('NOT_FOUND');}
async function chargeOrder(total: number): AsyncResult<Payment, 'PAYMENT_FAILED'> { const payment = await paymentService.charge(total); return payment.success ? ok(payment) : err('PAYMENT_FAILED');}
async function sendConfirmation(email: string): AsyncResult<void, 'EMAIL_FAILED'> { try { await emailService.sendConfirmation(email); return ok(undefined); } catch { return err('EMAIL_FAILED'); }}
// Compose in a workflowconst processOrder = createWorkflow('workflow', { findOrder, chargeOrder, sendConfirmation,});
const result = await processOrder.run(async ({ step, deps }) => { const order = await step('findOrder', () => deps.findOrder(orderId)); await step('chargeOrder', () => deps.chargeOrder(order.total)); await step('sendConfirmation', () => deps.sendConfirmation(order.email)); return order;});
if (result.ok) { console.log('Order processed:', result.value);} else { console.log('Failed with:', result.error); // result.error is typed as: 'NOT_FOUND' | 'PAYMENT_FAILED' | 'EMAIL_FAILED' | UnexpectedError}Converting throwing functions
Section titled “Converting throwing functions”Use step.try to wrap existing throwing code:
// Existing throwing functionasync function riskyOperation(): Promise<Data> { // May throw return await externalApi.call();}
// In workflowconst result = await workflow.run(async ({ step, deps }) => { const data = await step.try( 'riskyOp', () => deps.riskyOperation(), { error: 'OPERATION_FAILED' as const } ); return data;});Or use fromPromise for standalone conversions:
import { fromPromise } from 'awaitly';
const result = await fromPromise(riskyOperation());// result: Result<Data, PromiseRejectedError>Gradual adoption
Section titled “Gradual adoption”You don’t have to convert everything at once. Start with new code:
// Keep existing try/catch codeasync function legacyFunction() { try { return await oldService.call(); } catch { return null; }}
// Wrap in Result for new workflowasync function wrappedLegacy(): AsyncResult<Data, 'LEGACY_FAILED'> { const result = await legacyFunction(); return result ? ok(result) : err('LEGACY_FAILED');}
// Use in new workflowconst workflow = createWorkflow('workflow', { wrappedLegacy, newFunction });From neverthrow
Section titled “From neverthrow”awaitly and neverthrow share similar concepts, so migration is straightforward.
Result types
Section titled “Result types”import { ok, err, Result, ResultAsync } from 'neverthrow';
function divide(a: number, b: number): Result<number, string> { if (b === 0) return err('Division by zero'); return ok(a / b);}
async function fetchUser(id: string): ResultAsync<User, 'NOT_FOUND'> { // ...}import { ok, err, type Result, type AsyncResult } from 'awaitly';
function divide(a: number, b: number): Result<number, string> { if (b === 0) return err('Division by zero'); return ok(a / b);}
async function fetchUser(id: string): AsyncResult<User, 'NOT_FOUND'> { // ...}Chaining operations
Section titled “Chaining operations”const result = await fetchUser(id) .andThen(user => fetchPosts(user.id)) .map(posts => posts.length);import { andThen, map } from 'awaitly';
// Functional styleconst posts = await andThen( await fetchUser(id), (user) => fetchPosts(user.id));const count = map(posts, (p) => p.length);
// Or use workflows for complex chainsconst result = await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser(id)); const posts = await step('fetchPosts', () => deps.fetchPosts(user.id)); return posts.length;});Pattern matching
Section titled “Pattern matching”result.match( (value) => console.log('Success:', value), (error) => console.log('Error:', error));import { match } from 'awaitly';
match(result, { ok: (value) => console.log('Success:', value), err: (error) => console.log('Error:', error),});Combining results
Section titled “Combining results”import { Result } from 'neverthrow';
const combined = Result.combine([result1, result2, result3]);import { all, allAsync } from 'awaitly';
// Sync resultsconst combined = all([result1, result2, result3]);
// Async resultsconst combined = await allAsync([ fetchUser(id), fetchPosts(id), fetchComments(id),]);Key differences
Section titled “Key differences”| Feature | neverthrow | awaitly |
|---|---|---|
| Async type | ResultAsync<T, E> | AsyncResult<T, E> (type alias) |
| Chaining | Method chaining | Functions + workflows |
| Error inference | Manual | Automatic in workflows |
| Step control | - | Retries, timeouts, caching |
From Effect
Section titled “From Effect”Effect is a powerful functional programming library. If you want to gradually adopt awaitly alongside Effect, or migrate simpler parts of your codebase, this section shows you how.
Why consider awaitly?
Section titled “Why consider awaitly?”Effect excels when you need:
- Structured concurrency with fibers
- Layer-based dependency injection at scale
- Full ecosystem (Schema, Stream, etc.)
awaitly is simpler when:
- Your team finds generator syntax (
Effect.gen) unfamiliar - You don’t need Effect’s full ecosystem
- You want familiar
async/awaitsyntax with Result types - You need built-in workflows without complex abstractions
Basic conversions
Section titled “Basic conversions”import { Effect, Context, Layer } from 'effect';
// Service definition with Layer DIclass UserService extends Context.Tag('UserService')< UserService, { getUser: (id: string) => Effect.Effect<User, 'NOT_FOUND'> }>() {}
const program = Effect.gen(function* () { const service = yield* UserService; const user = yield* service.getUser('1'); return user;});
const result = await Effect.runPromise( program.pipe(Effect.provide(UserServiceLive)));import { ok, err, type AsyncResult } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';
// Simple function with Result typeconst getUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { const user = await db.findUser(id); return user ? ok(user) : err('NOT_FOUND');};
// Workflow with dependency injectionconst workflow = createWorkflow('workflow', { getUser });
const result = await workflow.run(async ({ step, deps }) => { const user = await step('getUser', () => deps.getUser('1')); return user;});API mapping
Section titled “API mapping”| Effect | awaitly | Notes |
|---|---|---|
Effect.succeed(x) | ok(x) | Simpler |
Effect.fail(e) | err(e) | Simpler |
Effect.gen(function* () { ... }) | run(async ({ step }) => { ... }) | Familiar async/await |
yield* someEffect | await step('id', fn) | Unwrap in workflow |
Effect.tryPromise({ try, catch }) | step.try('id', () => ..., { onError }) | Similar; id first |
Effect.all([...]) | step.all('name', { ... }) or step.parallel('name', { ... }) or allAsync([...]) | Similar; step.all is Effect-style alias |
Schedule.exponential() | step.retry('id', fn, { backoff: 'exponential' }) | Built-in options; id first |
Effect.timeout(duration) | step.withTimeout('id', fn, { ms: 5000 }) | Timeout is its own helper |
Layer.provide() | Pass deps to workflow | Direct injection |
Interop utilities
Section titled “Interop utilities”Use these helpers to bridge Effect and awaitly code during gradual migration:
import { Effect, Exit, Cause, Option } from 'effect';import { ok, err, type Result, type AsyncResult } from 'awaitly';
/** * Convert an Effect to an awaitly AsyncResult */export const fromEffect = async <A, E>( effect: Effect.Effect<A, E>): AsyncResult<A, E> => { const exit = await Effect.runPromiseExit(effect);
if (Exit.isSuccess(exit)) { return ok(exit.value); }
// Extract the failure from the Cause const failureOption = Cause.failureOption(exit.cause); if (Option.isSome(failureOption)) { return err(failureOption.value); }
// Handle defects (unexpected errors) - rethrow as these are bugs throw new Error('Effect failed with defect: ' + Cause.pretty(exit.cause));};
/** * Convert an awaitly AsyncResult to an Effect */export const toEffect = <A, E>( asyncResult: AsyncResult<A, E>): Effect.Effect<A, E> => Effect.tryPromise({ try: async () => { const result = await asyncResult; if (!result.ok) { throw result.error; } return result.value; }, catch: (e) => e as E, });
/** * Wrap an Effect-returning function for use in awaitly workflows */export const wrapEffect = <Args extends unknown[], A, E>( fn: (...args: Args) => Effect.Effect<A, E>) => async (...args: Args): AsyncResult<A, E> => { return fromEffect(fn(...args));};
/** * Wrap an awaitly function for use in Effect programs */export const wrapAwaitly = <Args extends unknown[], A, E>( fn: (...args: Args) => AsyncResult<A, E>) => (...args: Args): Effect.Effect<A, E> => { return toEffect(fn(...args));};Using interop in workflows
Section titled “Using interop in workflows”import { fromEffect, wrapEffect } from '@/lib/effect-interop';import { legacyEffectService } from '@/legacy/services';import { createWorkflow } from 'awaitly/workflow';
// Option 1: Inline conversionconst result = await workflow.run(async ({ step, deps }) => { const user = await step('getUser', fromEffect(legacyEffectService.getUser('1'))); return user;});
// Option 2: Pre-wrap Effect functionsconst getUser = wrapEffect(legacyEffectService.getUser);const getPosts = wrapEffect(legacyEffectService.getPosts);
const workflow = createWorkflow('workflow', { getUser, getPosts });
const result = await workflow.run(async ({ step, deps }) => { const user = await step('getUser', () => deps.getUser('1')); const posts = await step('getPosts', () => deps.getPosts(user.id)); return { user, posts };});Gradual migration strategy
Section titled “Gradual migration strategy”Phase 1: Parallel adoption
Keep Effect in existing code, use awaitly for new features:
// Existing: Keep Effect code as-isexport const processPayment = (input: PaymentInput) => Effect.gen(function* () { // ... existing Effect code });
// New: Use awaitly for new features// src/features/checkout/workflow.tsimport { wrapEffect } from '@/lib/effect-interop';import { processPayment } from '@/services/legacy-payment';
const checkoutWorkflow = createWorkflow('workflow', { validateCart, processPayment: wrapEffect(processPayment), // Bridge to Effect sendConfirmation,});Phase 2: Module-by-module migration
When refactoring a module, convert the entire module:
// Before: Effect with Layersexport class UserService extends Context.Tag('UserService')< UserService, { getUser: (id: string) => Effect.Effect<User, 'NOT_FOUND'>; updateUser: (id: string, data: Partial<User>) => Effect.Effect<User, 'NOT_FOUND' | 'UPDATE_ERROR'>; }>() {}
// After: awaitly with simple functionsexport const getUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> => { const user = await db.findUser(id); return user ? ok(user) : err('NOT_FOUND');};
export const updateUser = async ( id: string, data: Partial<User>): AsyncResult<User, 'NOT_FOUND' | 'UPDATE_ERROR'> => { const user = await db.findUser(id); if (!user) return err('NOT_FOUND');
try { const updated = await db.updateUser(id, data); return ok(updated); } catch { return err('UPDATE_ERROR'); }};When to keep Effect
Section titled “When to keep Effect”Don’t migrate everything. Keep Effect when:
- Using fibers for structured concurrency
- Heavy use of Layers for complex dependency graphs
- Using Effect ecosystem (Schema, Stream, Platform)
- Team is fluent in Effect patterns
- Code is stable and working well
Complete migration example
Section titled “Complete migration example”import { Effect, Context, Layer, Schedule, Duration } from 'effect';
class DbService extends Context.Tag('DbService')<DbService, Db>() {}class ProviderService extends Context.Tag('ProviderService')<ProviderService, Provider>() {}
const createPayment = (raw: unknown, actorEmail: string) => Effect.gen(function* () { const db = yield* DbService; const provider = yield* ProviderService;
const input = yield* Effect.try({ try: () => CreatePaymentSchema.parse(raw), catch: () => new ValidationError('Invalid input'), });
const existing = yield* Effect.promise(() => db.findPaymentByKey(input.idemKey)); if (existing) { return { paymentId: existing.id }; }
const locked = yield* Effect.promise(() => db.acquireLock(input.idemKey)); if (!locked) { return yield* Effect.fail(new IdempotencyConflict('Concurrent request')); }
const response = yield* Effect.tryPromise({ try: () => provider.createPayment({ amountMinor: input.amountMinor, currency: input.currency, reference: input.reference, }), catch: (e) => mapProviderError(e), }).pipe( Effect.timeout(Duration.millis(2000)), Effect.retry( Schedule.exponential(Duration.millis(200)).pipe( Schedule.jittered, Schedule.intersect(Schedule.recurs(2)) ) ) );
yield* Effect.tryPromise({ try: () => db.transaction(async (tx) => { await tx.insertPayment({ ...input, providerPaymentId: response.id, status: response.status }); await tx.insertAudit({ actor: actorEmail, action: 'PAYMENT_CREATED', metadata: { providerId: response.id } }); }), catch: () => new PersistError('Failed to save'), });
return { paymentId: response.id }; });
// Usageconst result = await Effect.runPromise( createPayment(rawInput, 'user@example.com').pipe( Effect.provide(Layer.merge(makeDbLayer(db), makeProviderLayer(provider))) ));import { ok, err, type AsyncResult } from 'awaitly';import { createWorkflow } from 'awaitly/workflow';
const deps = { validateInput: (raw: unknown): AsyncResult<CreatePayment, 'VALIDATION_ERROR'> => Promise.resolve( CreatePaymentSchema.safeParse(raw).success ? ok(CreatePaymentSchema.parse(raw)) : err('VALIDATION_ERROR') ),
findExisting: (db: Db, idemKey: string): AsyncResult<{ id: string } | undefined, never> => Promise.resolve(db.findPaymentByKey(idemKey)).then(ok),
acquireLock: (db: Db, idemKey: string): AsyncResult<void, 'IDEMPOTENCY_CONFLICT'> => Promise.resolve(db.acquireLock(idemKey)) .then(locked => locked ? ok(undefined) : err('IDEMPOTENCY_CONFLICT')),
callProvider: async (provider: Provider, input: CreatePayment): AsyncResult<ProviderResponse, 'PROVIDER_ERROR'> => { for (let attempt = 1; attempt <= 3; attempt++) { try { const response = await provider.createPayment({ amountMinor: input.amountMinor, currency: input.currency, reference: input.reference, }); return ok(response); } catch { if (attempt < 3) { await new Promise(r => setTimeout(r, 200 * 2 ** attempt)); } } } return err('PROVIDER_ERROR'); },
persistSuccess: async (db: Db, input: CreatePayment, response: ProviderResponse, actorEmail: string): AsyncResult<{ paymentId: string }, 'PERSIST_ERROR'> => { try { await db.transaction(async (tx) => { await tx.insertPayment({ ...input, providerPaymentId: response.id, status: response.status }); await tx.insertAudit({ actor: actorEmail, action: 'PAYMENT_CREATED', metadata: { providerId: response.id } }); }); return ok({ paymentId: response.id }); } catch { return err('PERSIST_ERROR'); } },};
const createPaymentWorkflow = (db: Db, provider: Provider, raw: unknown, actorEmail: string) => { const workflow = createWorkflow('workflow', deps);
return workflow.run(async ({ step, deps }) => { const input = await step('validateInput', () => deps.validateInput(raw), { key: 'validate' });
const existing = await step('findExisting', () => deps.findExisting(db, input.idemKey), { key: 'existing' }); if (existing) { return { paymentId: existing.id }; }
await step('acquireLock', () => deps.acquireLock(db, input.idemKey), { key: 'lock' }); const response = await step('callProvider', () => deps.callProvider(provider, input), { key: 'provider' }); return await step('persistSuccess', () => deps.persistSuccess(db, input, response, actorEmail), { key: 'persist' }); });};
// Usage - no Layer setup neededconst result = await createPaymentWorkflow(db, provider, rawInput, 'user@example.com');
if (result.ok) { console.log('Payment created:', result.value.paymentId);} else { // TypeScript knows: result.error is 'VALIDATION_ERROR' | 'IDEMPOTENCY_CONFLICT' | 'PROVIDER_ERROR' | 'PERSIST_ERROR' | UnexpectedError console.error('Failed:', result.error);}From custom Result types
Section titled “From custom Result types”Standard shape
Section titled “Standard shape”awaitly uses a standard Result shape:
// Success{ ok: true, value: T }
// Error{ ok: false, error: E, cause?: C }If your custom type uses different shapes, create adapters:
// Your existing typetype MyResult<T, E> = { success: true; data: T } | { success: false; err: E };
// Adapterfunction toAwaitly<T, E>(myResult: MyResult<T, E>): Result<T, E> { return myResult.success ? ok(myResult.data) : err(myResult.err);}
function fromAwaitly<T, E>(result: Result<T, E>): MyResult<T, E> { return result.ok ? { success: true, data: result.value } : { success: false, err: result.error };}Using adapters in workflows
Section titled “Using adapters in workflows”import { from } from 'awaitly';
// Wrap existing functionsasync function wrappedLegacy(id: string): AsyncResult<User, 'NOT_FOUND'> { const legacyResult = await legacyService.getUser(id); return toAwaitly(legacyResult);}
// Use in workflowconst workflow = createWorkflow('workflow', { wrappedLegacy });Migration checklist
Section titled “Migration checklist”Phase 1: Core functions
Section titled “Phase 1: Core functions”- Identify error types: List all the errors your functions can produce
- Add return type annotations:
AsyncResult<T, ErrorType> - Replace throw with return:
throw new Error()→return err('ERROR_TYPE') - Replace success returns:
return value→return ok(value)
Phase 2: Composition
Section titled “Phase 2: Composition”- Create workflows: Wrap related operations in
createWorkflow - Use step functions: Replace sequential awaits with
step() - Add error handling: Handle specific errors at the workflow boundary
Phase 3: Reliability
Section titled “Phase 3: Reliability”- Add retries: Use
step.retry()for transient failures - Add timeouts: Use
step.withTimeout()for slow operations - Add caching: Use step keys for idempotent operations
Phase 4: Testing
Section titled “Phase 4: Testing”- Update tests: Test Result values instead of catching errors
- Test error paths: Verify correct error types are returned
- Test workflows: Use
createWorkflowHarnessfor deterministic testing
Code mods (coming soon)
Section titled “Code mods (coming soon)”Getting help
Section titled “Getting help”- Troubleshooting Guide for common issues
- API Reference for detailed function docs
- GitHub Issues for bugs and questions