10·04Incremental Adoption

A production codebase will not be rewritten overnight, and trying produces a single unreviewable diff. Effect's `Effect.tryPromise` is a precise bridge: it converts a Promise, including one that rejects, into an `Effect` whose failure mode is named in the type

54 - Incremental Adoption: Bridge One Promise at a Time

Previous · Next

A production codebase will not be rewritten overnight, and trying produces a single unreviewable diff. Effect’s Effect.tryPromise is a precise bridge: it converts a Promise, including one that rejects, into an Effect whose failure mode is named in the type.

The rest of the codebase keeps using Promises. The two compose at the boundary you choose.

This step covers the constructors that bridge Promise code, the pattern for hiding them behind a service, and the order to migrate flows in.

What you’ll work through

index.test.ts contains three beats: lifting a single Promise call with Effect.tryPromise, hiding the bridge behind a service so callers don’t see it, and choosing the migration order — leaf calls first, composition root last.

By the end you should be able to introduce Effect into a Promise-based codebase without a single rewrite-the-world commit.


The Bridge

Promise side Effect side
------------ -----------
legacy.fetchUser(id) ─────────► Effect<User, UserRepoError>
may throw / reject failure named in E
retry / fallback available
composes with other Effects

Two constructors do the bridging, pick by whether the Promise can reject.


Effect.tryPromise

Creates an Effect from a function returning a Promise that may reject. The catch function maps the unknown rejection value into a typed error.

import { Data, Effect } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{
readonly cause: unknown
}> {}
// ┌─── Effect<unknown, FetchError, never>
// ▼
const fetchJson = (url: string) =>
Effect.tryPromise({
try: () => fetch(url).then((response) => response.json() as unknown),
catch: (cause) => new FetchError({ cause }),
})

Details

The try callback receives an AbortSignal if the effect is interrupted, pass it to fetch to make the request cancellable. The catch callback runs only on rejection; its return value lands in the E channel.

When to Use

Every Promise that can reject. That includes fetch, every SDK call, every fs/promises call, every database driver method.


Effect.promise

Creates an Effect from a function returning a Promise that never rejects. A rejection at runtime is treated as a defect.

import { Effect } from "effect"
// ┌─── Effect<number, never, never>
// ▼
const now = Effect.promise(() => Promise.resolve(Date.now()))

When to Use

In-memory operations: crypto.subtle.digest on a known input, a Promise.resolve, caches whose underlying store is guaranteed not to throw. The unconstrained version is the wrong default for I/O.


Hiding the Bridge Behind a Service

The bridge belongs at the seam between domain and infrastructure. Callers depend on a Context.Service; the implementation does the tryPromise once.

import { Context, Data, Effect } from "effect"
class UserRepoError extends Data.TaggedError("UserRepoError")<{
readonly cause: unknown
}> {}
type User = { readonly id: string; readonly name: string }
interface LegacyUserRepo {
readonly fetchUser: (id: string) => Promise<User>
}
class UserRepo extends Context.Service<UserRepo, {
readonly fetchUser: (id: string) => Effect.Effect<User, UserRepoError>
}>()("UserRepo") {}
const makeUserRepo = (legacy: LegacyUserRepo) => ({
fetchUser: (id: string) =>
Effect.tryPromise({
try: () => legacy.fetchUser(id),
catch: (cause) => new UserRepoError({ cause }),
}),
})

The caller never sees the legacy module:

// ┌─── Effect<string, UserRepoError, UserRepo>
// ▼
const greet = (id: string) =>
Effect.gen(function* () {
const repo = yield* UserRepo
const user = yield* repo.fetchUser(id)
return `Hello, ${user.name}`
})

The entrypoint is the one place where legacy and Effect coexist:

await Effect.runPromise(
greet("user-1").pipe(Effect.provideService(UserRepo, makeUserRepo(legacyRepo))),
)

Migration Order

Migrate in order of pain, not file size. Where Effect pays off fastest:

Priority Code shape Why it pays off
1 External API calls (fetch, third-party SDKs) Adds retry, timeout, typed failure
2 Payment / checkout flows Failures are expensive; surface them
3 Duplicated retry / backoff loops Replaced by Effect.retry + Schedule
4 Config loading at startup Config removes process-level crashes
5 Multi-step initialisation Sequencing with Effect.gen reads well
, Boring CRUD that already works Leave it. Migrating buys little.

Wrap a throwing legacy function with Effect.try

The first step of incremental adoption lives at the boundary between legacy and Effect code. Effect.try converts a throwing synchronous function into an Effect whose error channel holds a typed value — no exceptions cross the boundary into the rest of the program.

import { Effect, Data } from "effect"
class LegacyValidationError extends Data.TaggedError("LegacyValidationError")<{
readonly cause: unknown
}> {}
declare const legacyValidate: (input: string) => string // throws on bad input
const validate = (input: string) =>
Effect.try({
try: () => legacyValidate(input),
catch: (cause) => new LegacyValidationError({ cause }),
})
// ┌─── Effect<string, LegacyValidationError, never>

The thunk runs on every execution. The catch callback decides what typed shape the legacy exception takes before it enters E.

Downstream code never sees a raw exception — catchTag and retry work as if the legacy function had always been an Effect.

Your turn wrap a throwing legacy function with `Effect.try` and map any thrown value to a `LegacyValidationError`. Confirm valid input produces a `Success` exit and invalid input produces a `Failure` carrying the tagged error.


Wrap each Promise-returning function at the boundary

Every Promise-returning function at the legacy boundary gets its own Effect.tryPromise wrapper. The pattern is always the same: try runs the Promise, catch maps the rejection to a tagged error, and the resulting Effect slots into the surrounding pipeline.

There’s no shortcut — wrap each one, name each error.

import { Effect, Data } from "effect"
class UserRepoError extends Data.TaggedError("UserRepoError")<{
readonly cause: unknown
}> {}
declare const legacySaveUser: (user: User) => Promise<{ id: string }>
const saveUser = (user: User) =>
Effect.tryPromise({
try: () => legacySaveUser(user),
catch: (cause) => new UserRepoError({ cause }),
})
// ┌─── Effect<{ id: string }, UserRepoError, never>

Repetition at the boundary is the price of typed failure everywhere above it. Each wrapper is mechanical; do not abstract until you’ve written three and seen the shared shape.

Your turn wrap a Promise-returning legacy `saveUser` function with `Effect.tryPromise`, returning a tagged `UserRepoError` on rejection. Assert the success path resolves and the rejection path lands as a `Failure` carrying the tagged error.


Bridge with ManagedRuntime when call sites are scattered

Effect.runPromise is fine when every entrypoint can share a single Layer.provide. In a legacy codebase, every controller, every job, every React effect is its own call site.

ManagedRuntime.make builds the layer once and exposes runtime.runPromise (and runSync, runFork) that close over it — so every scattered call site picks up the same wired services without rebuilding the layer.

import { Effect, ManagedRuntime, Layer } from "effect"
const AppLayer = Layer.mergeAll(UserRepoLive, PaymentLive, LoggerLive)
// Build the runtime once at process boundary.
export const runtime = ManagedRuntime.make(AppLayer)
// Anywhere in the legacy codebase:
function handleHttpRequest(req: Request) {
return runtime.runPromise(saveUser(req.body))
}

The runtime owns the layer’s lifecycle — finalizers run when the runtime is disposed (await runtime.dispose()), which matters once Layers acquire connections or open files. For a process that never restarts, build once at startup and let it live for the lifetime of the program.

Your turn build a `ManagedRuntime` from a Layer that provides a `UserRepo` test double, then call `runtime.runPromise(saveUser(...))` from two different "call sites" and confirm both share the same service.


A Full Example

Wrapping a third-party Stripe-style charge function, hiding it behind a service, and composing it with a notification step.

import { Context, Data, Effect, Layer } from "effect"
class ChargeError extends Data.TaggedError("ChargeError")<{
readonly cause: unknown
}> {}
interface LegacyPaymentSdk {
readonly charge: (amount: number) => Promise<{ readonly id: string }>
}
class Payment extends Context.Service<Payment, { readonly charge: (amount: number) => Effect.Effect<string, ChargeError> }>()("Payment") {}
const PaymentLive = (sdk: LegacyPaymentSdk) =>
Layer.succeed(
Payment,
Payment.of({
charge: (amount) =>
Effect.tryPromise({
try: () => sdk.charge(amount).then((result) => result.id),
catch: (cause) => new ChargeError({ cause }),
}),
}),
)
// ┌─── Effect<string, ChargeError, Payment>
// ▼
const program = Effect.gen(function* () {
const payment = yield* Payment
const id = yield* payment.charge(2000)
yield* Effect.logInfo("charged", { id })
return id
})

The legacy SDK never leaves PaymentLive. Callers see a clean Effect-shaped service.


Common Pitfalls

1, Migrating everything at once. A single PR that touches every async function is unreviewable and reverts as a unit. Wrap one flow per change.

2, Using Effect.promise for code that can throw. Rejections become defects, which means typed recovery operators (catchTag, retry, mapError) cannot see them. Use Effect.tryPromise whenever rejection is possible.

3, Wrapping at the wrong layer. Wrapping inside business logic spreads the bridge across the codebase. Wrap once at the service boundary; everything upstream becomes pure Effect.

4, Ignoring the R channel. When the type becomes Effect<A, E, UserRepo | Payment | …>, the compiler is showing the project’s dependency graph. Provide each tag at the entrypoint; do not paper over with as any.


stubbed. - [ ] Lift a single Promise call with `Effect.tryPromise` — the typed failure mode in `E` is the payoff. - [ ] Hide the bridge behind a service so callers don't see the lift. - [ ] Read the migration-order beat — leaves first, composition root last.

See Also

  • Previous, 53-project-structure: the seam where tryPromise belongs.
  • Next, 57-http-client-patterns: the same bridge specialised for HTTP.
  • Prerequisites, 21-context-basics, 22-layers.

Run the tests: pnpm test 54-incremental-adoption


The full test suite

The index.test.ts for this lesson, verbatim from steps/54-incremental-adoption/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
import { Context, Data, Effect, Layer, ManagedRuntime } from "effect"
// =============================================================================
// 30 - Incremental Adoption: Bridge One Promise at a Time
// =============================================================================
//
// Wrap legacy Promise APIs with Effect.tryPromise, hide them behind a
// Context.Service, provide the implementation at the entrypoint.
class UserRepoError extends Data.TaggedError("UserRepoError")<{
readonly cause: unknown
}> {}
type LegacyUser = {
readonly id: string
readonly name: string
}
interface LegacyUserRepo {
readonly fetchUser: (id: string) => Promise<LegacyUser>
}
class UserRepo extends Context.Service<UserRepo, {
readonly fetchUser: (id: string) => Effect.Effect<LegacyUser, UserRepoError>
}>()("UserRepo") {}
const makeUserRepo = (legacy: LegacyUserRepo) => ({
fetchUser: (id: string) =>
Effect.tryPromise({
try: () => legacy.fetchUser(id),
catch: (cause) => new UserRepoError({ cause }),
}),
})
// ┌─── Effect<string, UserRepoError, UserRepo>
// ▼
const greet = (id: string) =>
Effect.gen(function* () {
const repo = yield* UserRepo
const user = yield* repo.fetchUser(id)
return `Hello, ${user.name}`
})
describe("30 - Incremental Adoption", () => {
// @beat
// id: try-promise
// shape: one-todo
// title: Wrap a Promise with tryPromise
// teaches: Effect.tryPromise converts a rejectable Promise into an Effect whose failure is a typed value in E.
// hint: predict what `greet("user-1")` returns when the repo's fetchUser resolves to { name: "Ada" }
it("wraps a Promise API without touching the call sites", async () => {
const legacyRepo: LegacyUserRepo = {
fetchUser: async (id) => ({ id, name: "Ada" }),
}
const result = await Effect.runPromise(
greet("user-1").pipe(Effect.provideService(UserRepo, makeUserRepo(legacyRepo))),
)
expect(result).toBe(/* @stub */ "Hello, Ada" /* @end */)
})
// @end-beat
// @beat
// id: hide-behind-service
// shape: multi-todo
// title: Hide the bridge behind a Context.Service
// teaches: A rejected Promise becomes a tagged UserRepoError in the E channel, ready for catchTag and retry.
// hint: the exit tag distinguishes success from failure; the inner error carries the tagged-error name defined on this file
it("converts a thrown exception into a typed Effect failure", async () => {
const legacyRepo: LegacyUserRepo = {
fetchUser: async () => {
throw new Error("database offline")
},
}
const exit = await Effect.runPromiseExit(
greet("user-1").pipe(Effect.provideService(UserRepo, makeUserRepo(legacyRepo))),
)
// Predict the exit shape and the tagged-error name surfaced through E.
expect(exit._tag).toBe(/* @stub */ "Failure" /* @end */)
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
expect(exit.cause.reasons[0].error._tag).toBe(/* @stub */ "UserRepoError" /* @end */)
}
})
// @end-beat
// @beat
// id: wrap-save-user
// shape: worked-then-your-turn
// title: Wrap a second Promise API the same way
// teaches: Every Promise-returning function at the boundary gets its own tryPromise wrapper; the pattern is always the same — try, catch, tagged error.
// hint: Effect.tryPromise({ try: () => legacy.saveUser(user), catch: (cause) => new UserWriteError({ cause }) })
describe("wrapping a write API", () => {
class UserWriteError extends Data.TaggedError("UserWriteError")<{
readonly cause: unknown
}> {}
class UserWrite extends Context.Service<UserWrite, { readonly saveUser: (user: LegacyUser) => Effect.Effect<void, UserWriteError> }>()("UserWrite") {}
it("worked example: fetchUser wraps a read Promise", async () => {
const legacyRepo: LegacyUserRepo = {
fetchUser: async (id) => ({ id, name: "Ada" }),
}
const result = await Effect.runPromise(
greet("user-1").pipe(Effect.provideService(UserRepo, makeUserRepo(legacyRepo))),
)
expect(result).toBe("Hello, Ada")
})
it("your turn: wrap saveUser with tryPromise the same way", async () => {
interface LegacyUserWrite {
readonly saveUser: (user: LegacyUser) => Promise<void>
}
const makeLegacyUserWrite = (legacy: LegacyUserWrite) => ({
saveUser: (user: LegacyUser): Effect.Effect<void, UserWriteError> =>
/* @stub */ Effect.tryPromise({
try: () => legacy.saveUser(user),
catch: (cause) => new UserWriteError({ cause }),
}) /* @end */,
})
const saved: LegacyUser[] = []
const legacyWrite: LegacyUserWrite = {
saveUser: async (user) => {
saved.push(user)
},
}
await Effect.runPromise(
Effect.gen(function* () {
const write = yield* UserWrite
yield* write.saveUser({ id: "u1", name: "Ada" })
}).pipe(Effect.provideService(UserWrite, makeLegacyUserWrite(legacyWrite))),
)
expect(saved).toEqual([{ id: "u1", name: "Ada" }])
})
})
// @end-beat
// @beat
// id: managed-runtime
// shape: multi-todo
// title: Share a Layer across call sites with ManagedRuntime
// teaches: ManagedRuntime.make builds the layer once and exposes runtime.runPromise — use it to integrate Effect into an existing codebase where every call site cannot share a single Effect entry point.
// hint: each greet call reuses the cached UserRepo, so the constructor body runs a fixed number of times across the two runPromise calls
it("ManagedRuntime builds the layer once and shares it across call sites", async () => {
let constructions = 0
const CountedRepo = Layer.sync(UserRepo, () => {
constructions += 1
return makeUserRepo({ fetchUser: async (id) => ({ id, name: "Ada" }) })
})
// ┌─── ManagedRuntime<UserRepo, never>
// ▼
const runtime = ManagedRuntime.make(CountedRepo)
// Two independent runPromise calls — the layer is only built once.
const a = await runtime.runPromise(greet("u1"))
const b = await runtime.runPromise(greet("u2"))
// Predict the greeting both calls return, and the construction count after sharing the runtime.
expect(a).toBe(/* @stub */ "Hello, Ada" /* @end */)
expect(b).toBe(/* @stub */ "Hello, Ada" /* @end */)
expect(constructions).toBe(/* @stub */ 1 /* @end */)
await runtime.dispose()
})
// @end-beat
// @beat
// id: wrap-legacy
// shape: one-todo
// title: Wrap a throwing legacy function in Effect.try
// teaches: Effect.try converts a throwing function into an Effect whose error channel holds a typed value — the first step of incremental adoption is at the boundary between legacy and Effect code.
// hint: Effect.try({ try: () => legacyFn(input), catch: (cause) => new YourError({ cause }) })
it("wraps a throwing legacy function and surfaces the exception as a typed error", () => {
class ParseError extends Data.TaggedError("ParseError")<{
readonly cause: unknown
}> {}
// Legacy function — throws on bad input, outside our control
const legacyParse = (input: string): { id: number } => {
const n = parseInt(input)
if (isNaN(n)) throw new TypeError(`Cannot parse: ${input}`)
return { id: n }
}
// Your turn: wrap legacyParse in Effect.try so bad input surfaces as ParseError in E.
const safeParse = (input: string) =>
/* @stub */ Effect.try({
try: () => legacyParse(input),
catch: (cause) => new ParseError({ cause }),
}) /* @end */
expect(Effect.runSync(safeParse("42"))).toEqual({ id: 42 })
const failExit = Effect.runSyncExit(safeParse("not-a-number"))
expect(failExit._tag).toBe("Failure")
})
// @end-beat
})
← PreviousProject StructureNext →Use Pattern