02·02Sync vs Async Constructors

Effect has four foundational constructors for "lift this side-effecting JS code into an Effect." They look similar at a glance and beginners pick the wrong one constantly. This step disambiguates them with a single decision tree: **sync vs async** on one axis,

08 - Sync vs Async Constructors

Previous · Next

Effect has four foundational constructors for “lift this side-effecting JS code into an Effect.” They look similar at a glance and beginners pick the wrong one constantly. This step disambiguates them with a single decision tree: sync vs async on one axis, safe vs throws on the other.

What you’ll work through

index.test.ts contains five beats — one for each of the four constructors (Effect.sync, Effect.try, Effect.promise, Effect.tryPromise), plus a mistake-first beat that shows what happens when the wrong constructor is applied to throwing code.

By the end you should be able to look at a piece of JS and pick the constructor whose contract honestly matches its behaviour, keeping the E channel accurate.


Mental Model

safe (never throws) may throw / may reject
─────────────────── ──────────────────────
sync (JS) Effect.sync Effect.try
async (Promise) Effect.promise Effect.tryPromise
  • sync lifts a plain JS function. No microtask is scheduled.
  • promise lifts a Promise. The Effect awaits it.
  • try variants capture exceptions / rejections into the E channel via a catch callback you supply. If the function cannot fail, use the non-try version — it returns Effect<A, never, never> and stays out of your error channel.

The E channel is the entire payoff. Picking the right constructor at the boundary is what keeps the channel honest the rest of the way.


Effect.sync — safe synchronous work

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

Use Effect.sync when the work is synchronous and cannot throw under any input you’ll feed it: reading Date.now(), generating a UUID, computing a derived value, pushing to a local buffer. The E channel stays never because there is no way for the captured function to fail.

> `sync` — the exception escapes the runner as a defect, not an `E`-channel > error. Defects bypass `catchTag` entirely (step 48 covers `Cause`). When > in doubt, prefer `Effect.try` and accept the slightly noisier signature.

Rule of thumb. sync is for local work the JavaScript runtime can’t fail at. The moment a library call is involved, ask: “does its documentation promise no throw under my inputs?” If not, use try.

Your turn wrap the `() => { read++; return 42 }` thunk in `Effect.sync(...)` so the side effect only fires when `runSync` walks the description. The test asserts `read === 0` before the runner and `read === 1` after — proof the constructor captured the work without firing it.


Effect.try — synchronous work that may throw

import { Effect, Data } from "effect"
class ParseError extends Data.TaggedError("ParseError")<{
readonly cause: unknown
}> {}
// ┌─── Effect<unknown, ParseError, never>
// ▼
const parse = (input: string) =>
Effect.try({
try: () => JSON.parse(input),
catch: (cause) => new ParseError({ cause }),
})

Use Effect.try when the synchronous work can throw (JSON.parse, new URL, a third-party library that throws on bad input). The catch callback converts the thrown value — unknown — into a typed error you put in the E channel.

Your turn wrap `JSON.parse` so its `SyntaxError` becomes a typed `ParseError`.


Effect.promise — safe asynchronous work

import { Effect } from "effect"
// ┌─── Effect<string, never, never>
// ▼
const readBody = (response: Response) =>
Effect.promise(() => response.text())

Use Effect.promise when the work returns a Promise that you have a guarantee will not reject — e.g., a stream you know is exhausted, a library call documented as infallible, or a Promise.resolve you produced. The E channel stays never.

Warning: if the promise does reject, the rejection becomes a defect (an unrecoverable failure, see step 48 Cause & Sandbox), not an E error. If you are not certain the promise will resolve, use tryPromise instead.

Your turn lift a known-safe promise into an Effect.


Effect.tryPromise — asynchronous work that may reject

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

Use Effect.tryPromise for the realistic case: an async operation that may reject (network calls, file I/O, database queries). The catch callback converts the rejection — also unknown — into a typed E error.

Your turn wrap a function that returns a rejecting promise and confirm the typed error appears in `E`.


The wrong-constructor mistake

// ❌ Bug — sync wraps a function that throws. The throw becomes a defect, not
// an E-channel value. catchTag won't see it.
const bad = Effect.sync(() => JSON.parse("{bad json"))
// ✅ Fix — try captures the throw into the typed E channel.
const good = Effect.try({
try: () => JSON.parse("{bad json"),
catch: (cause) => new ParseError({ cause }),
})

Effect.sync and Effect.promise declare “this will not fail” — the type system stamps E = never. If the wrapped code does fail, the failure escapes the E channel and becomes a defect, which is much harder to recover from.

The try variants exist to keep that contract honest.

Your turn observe a failed `Effect.sync` producing a defect (the `Exit` reports a `Die`, not a `Fail`), then rewrite it with `Effect.try` and confirm the error surfaces in `E` as a typed `ParseError`.


A Full Example

import { Effect, Data } from "effect"
class FetchError extends Data.TaggedError("FetchError")<{ readonly cause: unknown }> {}
class ParseError extends Data.TaggedError("ParseError")<{ readonly cause: unknown }> {}
const fetchAndParse = (url: string) =>
Effect.gen(function* () {
// tryPromise — may reject
const response = yield* Effect.tryPromise({
try: () => fetch(url),
catch: (cause) => new FetchError({ cause }),
})
// promise — Response.text() is documented infallible once you hold a Response
const body = yield* Effect.promise(() => response.text())
// try — JSON.parse throws
const parsed = yield* Effect.try({
try: () => JSON.parse(body) as { id: string },
catch: (cause) => new ParseError({ cause }),
})
// sync — no failure possible
const receivedAt = yield* Effect.sync(() => Date.now())
return { ...parsed, receivedAt }
})

Each call site picks the constructor matching the actual contract of the underlying JS. The E channel of fetchAndParse is exactly FetchError | ParseError — the type tells you everything that can go wrong.


Common Pitfalls

1, Using Effect.sync on code that throws. The throw becomes a defect, not a typed failure. catchTag will not see it.

Use Effect.try.

2, Using Effect.promise on a fetch / DB call. Same trap, async edition. Network calls can always reject; use tryPromise.

3, Forgetting the catch callback’s return type. It must produce a value of your error type, not return the raw unknown. Wrap it: (cause) => new MyError({ cause }).

4, Reaching for Effect.fail from inside the wrapped function. Inside Effect.sync(() => ...) you write plain JS, not Effect. To fail from sync code, throw and catch the throw with Effect.try.


Rules of thumb

  • Can it throw / reject? Use try / tryPromise. The E channel earns its keep here.
  • Provably safe? Use sync / promise. The never in E is honest.
  • Synchronous vs Promise is just about whether you need to await. It is orthogonal to whether the work can fail.

are stubbed. - [ ] Complete `effect-sync`: wrap a side-effecting read so it fires only when the program runs. - [ ] Complete `effect-try`: wrap `JSON.parse` and map the exception to `ParseError`. - [ ] Complete `effect-promise`: lift a known-safe promise into `Effect`. - [ ] Complete `effect-try-promise`: wrap a rejecting promise and confirm the typed error appears in `E`. - [ ] Complete `picking-the-wrong-one`: see the defect first, then fix the constructor. - [ ] Note how each constructor maps to one quadrant of the sync/async × safe/throws table — step 13 revisits the async row.

See Also

  • Previous, 07-effect-gen: composing Effects in generator form.
  • Next, 09-error-handling: catchTag and friends — what to do with the E channel once the constructor has populated it correctly.
  • Related, 04-error-types: defining the tagged errors that try / tryPromise populate.
  • Later, 48-cause-and-sandbox: how a defect (from a wrongly-sync-wrapped throw) differs from a tracked failure.

Run the tests: pnpm test 08-sync-vs-async-constructors


The full test suite

The index.test.ts for this lesson, verbatim from steps/08-sync-vs-async-constructors/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
import { Cause, Data, Effect, Exit } from "effect"
class ReadError extends Data.TaggedError("ReadError")<{ readonly cause: unknown }> {}
describe("constructors", () => {
it("uses sync for lazy synchronous work", () => expect(Effect.runSync(Effect.sync(() => 42))).toBe(42))
it("maps thrown values into typed failures with try", () => {
const exit = Effect.runSyncExit(Effect.try({ try: () => { throw "bad" }, catch: (cause) => new ReadError({ cause }) }))
expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true)
})
it("uses die for defects", () => {
const exit = Effect.runSyncExit(Effect.die("broken invariant"))
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
})
})
← PreviousEffect.genNext →Error Handling