13 - Async Operations: Promises and Side Effects
Real programs talk to the outside world: HTTP servers, databases, file systems, clocks. This step covers the constructors that lift those operations into the Effect type, synchronous side effects, throwing thunks, Promises, and rejecting Promises, each matched to a constructor that captures its failure mode in the type.
What you’ll work through
index.test.ts contains four beats. Each one wraps a different kind of side-effecting JS in the matching constructor: a resolving Promise, a rejecting Promise, a sync thunk, and a mix of both inside one gen block.
By the end you should be able to look at any piece of JS and name the right constructor on sight — sync, try, promise, or tryPromise — without re-reading step 08.
The Four-Way Split
Two questions decide the wrapper: is the work synchronous or asynchronous, and can it fail with an exception?
cannot fail may fail ┌───────────────────┬─────────────────────┐ sync │ Effect.sync │ Effect.try │ ├───────────────────┼─────────────────────┤ async │ Effect.promise │ Effect.tryPromise │ └───────────────────┴─────────────────────┘ E = never E = your typed errorThe “cannot fail” wrappers (sync, promise) take a thunk you guarantee will not throw or reject. The “may fail” wrappers (try, tryPromise) take a catch callback that maps the unknown thrown or rejected value into a typed error.
Effect.promise
Creates an Effect from a thunk that returns a Promise you promise will never reject. The result is placed in the success channel A; E is never.
import { Effect } from "effect"
// ┌─── Effect<{ host: string; port: number }, never, never>// ▼const readConfig = Effect.promise(() => Promise.resolve({ host: "localhost", port: 5432 }))Details
The thunk is called lazily, every time the effect is executed. If the returned Promise does reject, the rejection becomes a defect: an unexpected failure that bypasses the typed E channel.
Defects can be observed with Effect.catchDefect or surfaced via Effect.sandbox, but they signal a bug in code you believed could not fail.
When to Use
When the Promise is produced by code you control and is guaranteed to resolve, in-memory caches, pre-resolved values, code paths that wrap a non-throwing API in a Promise solely to defer work.
@see Effect.tryPromise for a version that catches rejections.
Your turn wrap a resolving Promise with `Effect.promise` so the test reads `42` from the success channel.
Effect.tryPromise
Creates an Effect from a thunk that returns a Promise that may reject. The catch function maps the unknown rejection into a typed error placed in E.
import { Effect, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{ readonly url: string readonly cause: unknown}> {}
// ┌─── Effect<unknown, FetchError, never>// ▼const fetchJson = (url: string) => Effect.tryPromise({ try: () => fetch(url).then((r) => r.json() as Promise<unknown>), catch: (cause) => new FetchError({ url, cause }), })Details
The catch callback receives unknown, TypeScript cannot prove what a Promise rejects with. Narrow with instanceof or typeof before reading properties, or pass the raw value through as a cause.
Effect.tryPromise is the asynchronous counterpart of Effect.try. Both turn untyped exceptions into typed values; only the synchrony differs.
When to Use
Any time the underlying Promise can reject, fetch, database drivers, file I/O, library calls. The default for almost every async wrapper in application code.
Your turn fill in the `try`/`catch` pair so a rejecting Promise becomes a `FetchError`.
Effect.sync and Effect.try
Synchronous counterparts, covered in detail in 05-effect-basics. Same split as the Promise wrappers: sync for code you guarantee will not throw, try for code that may.
import { Effect } from "effect"
// No exceptions possible, counters, in-memory mutation// ┌─── Effect<number, never, never>// ▼const now = Effect.sync(() => Date.now())
// May throw, JSON.parse, third-party sync code// ┌─── Effect<unknown, Error, never>// ▼const parse = (input: string) => Effect.try({ try: () => JSON.parse(input) as unknown, catch: (cause) => new Error(String(cause)), })Your turn wrap the counter with `Effect.sync` and the bad JSON with `Effect.try`.
A Full Example
Sequencing a configuration read, a network call, and a follow-up call inside Effect.gen. Each step short-circuits the rest on failure; the error channel widens to the union of all typed errors that can occur.
import { Effect, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{ readonly url: string readonly cause: unknown}> {}
declare const fetchUser: (id: string) => Promise<{ id: string; name: string }>declare const fetchPosts: (userId: string) => Promise<Array<{ id: string }>>
// ┌─── Effect<{ user: { id: string; name: string }; posts: Array<{ id: string }> }, FetchError, never>// ▼const program = Effect.gen(function* () { const config = yield* Effect.sync(() => ({ userId: "123" }))
const user = yield* Effect.tryPromise({ try: () => fetchUser(config.userId), catch: (cause) => new FetchError({ url: "/users", cause }), })
const posts = yield* Effect.tryPromise({ try: () => fetchPosts(user.id), catch: (cause) => new FetchError({ url: "/posts", cause }), })
return { user, posts }})Run with Effect.runPromise because the program contains asynchronous steps. runSync would throw, there is no event loop to await on.
Your turn sequence the sync config read and two `Effect.tryPromise` calls inside `Effect.gen`.
Rules of thumb
- Reach for
tryPromise99% of the time.promiseis reserved for verified-safe paths; bare network/DB calls are not those. - Convert rejections into typed errors at the boundary. A
catchcallback there saves you a ten-step debugging trip later. - Once inside Effect, stay there. Resist dropping back to
then/awaitmid-workflow — you lose interrupt safety and tracing.
Common Pitfalls
1, Effect.promise on a Promise that can reject. The rejection becomes an untyped defect, not a recoverable error. Reach for Effect.tryPromise whenever rejection is possible.
2, Treating catch as if it received an Error. A Promise can reject with any value, a string, a number, a plain object. catch always receives unknown.
Narrow before reading properties.
3, Calling runSync on an async effect. Synchronous runners cannot wait. Use runPromise or runPromiseExit.
4, Throwing inside Effect.sync or Effect.promise. Both wrappers promise no exceptions. A throw or rejection becomes a defect, outside the typed error channel.
stay `never`. - [ ] Wrap a rejecting Promise with `Effect.tryPromise` — the rejection should become a typed value in `E`. - [ ] Cover the sync counterparts (`Effect.sync` / `Effect.try`) and then mix sync + async inside one `gen` block. The runtime resolves the async pieces invisibly.
See Also
- Previous,
09-error-handling: recovering from typed errors. - Next,
12-combining-effects: running multiple effects in parallel. - Related,
05-effect-basics:Effect.syncandEffect.tryin depth. - Later,
32-retry-and-timeout: retrying and bounding async effects.
Run the tests: pnpm test 13-async-operations
The full test suite
The index.test.ts for this lesson, verbatim from steps/13-async-operations/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Data, Exit } from "effect"
// =============================================================================// 08 - Async Operations: Promises and Side Effects// =============================================================================//// Four wrappers, split by synchrony and failure mode:// Effect.sync sync, no throw (E = never)// Effect.try sync, may throw (E = your typed error)// Effect.promise async, no rejection (E = never)// Effect.tryPromise async, may reject (E = your typed error)
class FetchError extends Data.TaggedError("FetchError")<{ readonly url: string readonly cause: unknown}> {}
class ParseError extends Data.TaggedError("ParseError")<{ readonly input: string}> {}
describe("08 - Async Operations", () => { // @beat // id: wrap-resolving-promise // shape: one-todo // title: Wrap a Promise you trust // teaches: Effect.promise lifts a non-rejecting Promise; E stays never. // hint: Effect.promise takes a thunk returning a Promise describe("Effect.promise", () => { it("wraps a Promise you guarantee will resolve", async () => { // ┌─── Effect<number, never, never> // ▼ const program = /* @stub */ Effect.promise(() => Promise.resolve(42)) /* @end */
await expect(Effect.runPromise(program)).resolves.toBe(42) })
it("re-invokes the thunk on every execution", async () => { let calls = 0 const program = /* @stub */ Effect.promise(() => { calls += 1 return Promise.resolve(calls) }) /* @end */
await expect(Effect.runPromise(program)).resolves.toBe(1) await expect(Effect.runPromise(program)).resolves.toBe(2) }) }) // @end-beat
// @beat // id: wrap-rejecting-promise // shape: multi-todo // title: Catch a Promise rejection as a typed error // teaches: Effect.tryPromise maps an unknown rejection into a typed E via catch. // hint: Effect.tryPromise takes { try, catch }; catch returns your domain error describe("Effect.tryPromise", () => { it("catches a rejection into a typed error", async () => { const failing = /* @stub */ Effect.tryPromise({ try: () => Promise.reject(new Error("boom")), catch: (cause) => new FetchError({ url: "/x", cause }), }) /* @end */
const exit = await Effect.runPromiseExit(failing) expect(Exit.isFailure(exit)).toBe(true) })
it("returns the resolved value when the Promise succeeds", async () => { const ok = /* @stub */ Effect.tryPromise({ try: () => Promise.resolve({ data: "hello" }), catch: (cause) => new FetchError({ url: "/x", cause }), }) /* @end */
await expect(Effect.runPromise(ok)).resolves.toEqual({ data: "hello" }) })
it("catch maps the unknown rejection to a typed structured error", async () => { const program = Effect.tryPromise({ try: () => Promise.reject({ status: 404, message: "Not found" }), catch: /* @stub */ (raw) => { const err = raw as { status?: number; message?: string } return new FetchError({ url: "/missing", cause: { status: err.status ?? 0, message: err.message ?? "" }, }) } /* @end */, })
const exit = await Effect.runPromiseExit(program) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit) && exit.cause.reasons[0]?._tag === "Fail") { expect(exit.cause.reasons[0].error._tag).toBe("FetchError") expect(exit.cause.reasons[0].error.url).toBe("/missing") } }) }) // @end-beat
// @beat // id: sync-counterparts // shape: multi-todo // title: Sync counterparts alongside the async wrappers // teaches: Effect.sync and Effect.try are the synchronous pair; the failure split is the same. // hint: Effect.sync for non-throwing thunks; Effect.try for throwing ones describe("Effect.sync and Effect.try alongside the async wrappers", () => { it("Effect.sync defers a non-throwing side effect", () => { let value = 0 const program = /* @stub */ Effect.sync(() => { value += 1 return value }) /* @end */
expect(value).toBe(0) expect(Effect.runSync(program)).toBe(1) })
it("Effect.try catches a throw into a typed error", () => { const program = /* @stub */ Effect.try({ try: () => JSON.parse("{ invalid") as unknown, catch: () => new ParseError({ input: "{ invalid" }), }) /* @end */
const exit = Effect.runSyncExit(program) expect(Exit.isFailure(exit)).toBe(true) }) }) // @end-beat
// @beat // id: mix-sync-and-async // shape: multi-todo // title: Sequence sync and async in Effect.gen // teaches: Effect.gen sequences sync and async wrappers; runPromise drives the async result. // hint: yield* each wrapper in order; use Effect.tryPromise for the awaited calls describe("composite — mixing sync and async in Effect.gen", () => { it("sequences sync config, async user fetch, async posts fetch", async () => { const fetchUser = async (id: string) => ({ id, name: "Alice" }) const fetchPosts = async (_userId: string) => [{ id: "p1" }]
// ┌─── Effect<{ user: { id: string; name: string }; posts: Array<{ id: string }> }, FetchError, never> // ▼ const program = Effect.gen(function* () { const config = yield* /* @stub */ Effect.sync(() => ({ userId: "123" })) /* @end */
const user = yield* /* @stub */ Effect.tryPromise({ try: () => fetchUser(config.userId), catch: (cause) => new FetchError({ url: "/users", cause }), }) /* @end */
const posts = yield* /* @stub */ Effect.tryPromise({ try: () => fetchPosts(user.id), catch: (cause) => new FetchError({ url: "/posts", cause }), }) /* @end */
return { user, posts } })
const result = await Effect.runPromise(program) expect(result.user.name).toBe("Alice") expect(result.posts).toHaveLength(1) }) }) // @end-beat})