08 - Sync 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, 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. tryvariants capture exceptions / rejections into theEchannel via acatchcallback you supply. If the function cannot fail, use the non-tryversion — it returnsEffect<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.
Rule of thumb.
syncis 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, usetry.
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
Eerror. If you are not certain the promise will resolve, usetryPromiseinstead.
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. TheEchannel earns its keep here. - Provably safe? Use
sync/promise. TheneverinEis 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:catchTagand friends — what to do with theEchannel once the constructor has populated it correctly. - Related,
04-error-types: defining the tagged errors thattry/tryPromisepopulate. - 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/.
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) })})