Skip to content

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.

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;
}
}

Use step.try to wrap existing throwing code:

// Existing throwing function
async function riskyOperation(): Promise<Data> {
// May throw
return await externalApi.call();
}
// In workflow
const 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>

You don’t have to convert everything at once. Start with new code:

// Keep existing try/catch code
async function legacyFunction() {
try {
return await oldService.call();
} catch {
return null;
}
}
// Wrap in Result for new workflow
async function wrappedLegacy(): AsyncResult<Data, 'LEGACY_FAILED'> {
const result = await legacyFunction();
return result ? ok(result) : err('LEGACY_FAILED');
}
// Use in new workflow
const workflow = createWorkflow('workflow', { wrappedLegacy, newFunction });

awaitly and neverthrow share similar concepts, so migration is straightforward.

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'> {
// ...
}
const result = await fetchUser(id)
.andThen(user => fetchPosts(user.id))
.map(posts => posts.length);
result.match(
(value) => console.log('Success:', value),
(error) => console.log('Error:', error)
);
import { Result } from 'neverthrow';
const combined = Result.combine([result1, result2, result3]);
Featureneverthrowawaitly
Async typeResultAsync<T, E>AsyncResult<T, E> (type alias)
ChainingMethod chainingFunctions + workflows
Error inferenceManualAutomatic in workflows
Step control-Retries, timeouts, caching

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.

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/await syntax with Result types
  • You need built-in workflows without complex abstractions
import { Effect, Context, Layer } from 'effect';
// Service definition with Layer DI
class 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))
);
EffectawaitlyNotes
Effect.succeed(x)ok(x)Simpler
Effect.fail(e)err(e)Simpler
Effect.gen(function* () { ... })run(async ({ step }) => { ... })Familiar async/await
yield* someEffectawait 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 workflowDirect injection

Use these helpers to bridge Effect and awaitly code during gradual migration:

src/lib/effect-interop.ts
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));
};
import { fromEffect, wrapEffect } from '@/lib/effect-interop';
import { legacyEffectService } from '@/legacy/services';
import { createWorkflow } from 'awaitly/workflow';
// Option 1: Inline conversion
const result = await workflow.run(async ({ step, deps }) => {
const user = await step('getUser', fromEffect(legacyEffectService.getUser('1')));
return user;
});
// Option 2: Pre-wrap Effect functions
const 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 };
});

Phase 1: Parallel adoption

Keep Effect in existing code, use awaitly for new features:

src/services/legacy-payment.ts
// Existing: Keep Effect code as-is
export const processPayment = (input: PaymentInput) =>
Effect.gen(function* () {
// ... existing Effect code
});
// New: Use awaitly for new features
// src/features/checkout/workflow.ts
import { 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 Layers
export 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 functions
export 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');
}
};

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
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 };
});
// Usage
const result = await Effect.runPromise(
createPayment(rawInput, 'user@example.com').pipe(
Effect.provide(Layer.merge(makeDbLayer(db), makeProviderLayer(provider)))
)
);

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 type
type MyResult<T, E> = { success: true; data: T } | { success: false; err: E };
// Adapter
function 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 };
}
import { from } from 'awaitly';
// Wrap existing functions
async function wrappedLegacy(id: string): AsyncResult<User, 'NOT_FOUND'> {
const legacyResult = await legacyService.getUser(id);
return toAwaitly(legacyResult);
}
// Use in workflow
const workflow = createWorkflow('workflow', { wrappedLegacy });
  1. Identify error types: List all the errors your functions can produce
  2. Add return type annotations: AsyncResult<T, ErrorType>
  3. Replace throw with return: throw new Error()return err('ERROR_TYPE')
  4. Replace success returns: return valuereturn ok(value)
  1. Create workflows: Wrap related operations in createWorkflow
  2. Use step functions: Replace sequential awaits with step()
  3. Add error handling: Handle specific errors at the workflow boundary
  1. Add retries: Use step.retry() for transient failures
  2. Add timeouts: Use step.withTimeout() for slow operations
  3. Add caching: Use step keys for idempotent operations
  1. Update tests: Test Result values instead of catching errors
  2. Test error paths: Verify correct error types are returned
  3. Test workflows: Use createWorkflowHarness for deterministic testing